Index: ps/trunk/binaries/data/mods/public/globalscripts/AttackEffects.js =================================================================== --- ps/trunk/binaries/data/mods/public/globalscripts/AttackEffects.js (revision 24499) +++ ps/trunk/binaries/data/mods/public/globalscripts/AttackEffects.js (revision 24500) @@ -1,17 +1,61 @@ -// TODO: could be worth putting this in json files someday -const g_EffectTypes = ["Damage", "Capture", "ApplyStatus"]; -const g_EffectReceiver = { - "Damage": { - "IID": "IID_Health", - "method": "TakeDamage" - }, - "Capture": { - "IID": "IID_Capturable", - "method": "Capture", - "sound": "capture" - }, - "ApplyStatus": { - "IID": "IID_StatusEffectsReceiver", - "method": "ApplyStatus" +/** + * This class provides a cache for accessing attack effects stored in JSON files. + */ +class AttackEffects +{ + constructor() + { + let effectsDataObj = {}; + this.effectReceivers = []; + this.effectSounds = {}; + + for (let filename of Engine.ListDirectoryFiles("simulation/data/attack_effects", "*.json", false)) + { + let data = Engine.ReadJSONFile(filename); + if (!data) + continue; + + if (effectsDataObj[data.code]) + { + error("Encountered two effect types with the code " + data.name + "."); + continue; + } + + effectsDataObj[data.code] = data; + + this.effectReceivers.push({ + "type": data.code, + "IID": data.IID, + "method": data.method + }); + this.effectSounds[data.code] = data.sound || ""; + } + + let effDataSort = (a, b) => a.order < b.order ? -1 : a.order > b.order ? 1 : 0; + let effSort = (a, b) => effDataSort( + effectsDataObj[a.type], + effectsDataObj[b.type] + ); + this.effectReceivers.sort(effSort); + + deepfreeze(this.effectReceivers); + deepfreeze(this.effectSounds); } -}; + + /** + * @return {Object[]} - The effects possible with their data. + */ + Receivers() + { + return this.effectReceivers; + } + + /** + * @param {string} type - The type of effect to get the receiving sound for. + * @return {string} - The name of the soundgroup to play. + */ + GetSound(type) + { + return this.effectSounds[type] || ""; + } +} Property changes on: ps/trunk/binaries/data/mods/public/globalscripts/AttackEffects.js ___________________________________________________________________ Deleted: svn:executable ## -1 +0,0 ## -* \ No newline at end of property Index: ps/trunk/binaries/data/mods/public/globalscripts/tests/test_AttackEffects.js =================================================================== --- ps/trunk/binaries/data/mods/public/globalscripts/tests/test_AttackEffects.js (nonexistent) +++ ps/trunk/binaries/data/mods/public/globalscripts/tests/test_AttackEffects.js (revision 24500) @@ -0,0 +1,31 @@ +let effects = { + "eff_A": { + "code": "a", + "name": "A", + "order": "2", + "IID": "IID_A", + "method": "doA" + }, + "eff_B": { + "code": "b", + "name": "B", + "order": "1", + "IID": "IID_B", + "method": "doB" + } +}; + +Engine.ListDirectoryFiles = () => Object.keys(effects); +Engine.ReadJSONFile = (file) => effects[file]; + +let attackEffects = new AttackEffects(); + +TS_ASSERT_UNEVAL_EQUALS(attackEffects.Receivers(), [{ + "type": "b", + "IID": "IID_B", + "method": "doB" +}, { + "type": "a", + "IID": "IID_A", + "method": "doA" +}]); Property changes on: ps/trunk/binaries/data/mods/public/globalscripts/tests/test_AttackEffects.js ___________________________________________________________________ Added: svn:eol-style ## -0,0 +1 ## +native \ No newline at end of property Added: svn:mime-type ## -0,0 +1 ## +text/plain \ No newline at end of property Index: ps/trunk/binaries/data/mods/public/l10n/messages.json =================================================================== --- ps/trunk/binaries/data/mods/public/l10n/messages.json (revision 24499) +++ ps/trunk/binaries/data/mods/public/l10n/messages.json (revision 24500) @@ -1,808 +1,821 @@ [ { "output": "public-civilizations.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "json", "filemasks": [ "simulation/data/civs/**.json" ], "options": { "keywords": [ "Name", "Description", "History", "Special", "AINames" ] } } ] }, { "output": "public-gui-ingame.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "javascript", "filemasks": [ "gui/session/**.js" ], "options": { "format": "javascript-format", "keywords": { "translate": [1], "translatePlural": [1, 2], "translateWithContext": [[1], 2], "translatePluralWithContext": [[1], 2, 3], "markForTranslation": [1], "markForTranslationWithContext": [[1], 2], "markForPluralTranslation": [1, 2] }, "commentTags": [ "Translation:" ] } }, { "extractor": "xml", "filemasks": [ "gui/session/**.xml" ], "options": { "keywords": { "translatableAttribute": { "locationAttributes": ["id"] }, "translate": {} } } } ] }, { "output": "public-gui-gamesetup.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "javascript", "filemasks": [ "gui/gamesetup/**.js", "gui/gamesetup_mp/**.js", "gui/loading/**.js" ], "options": { "format": "javascript-format", "keywords": { "translate": [1], "translatePlural": [1, 2], "translateWithContext": [[1], 2], "translatePluralWithContext": [[1], 2, 3], "markForTranslation": [1], "markForTranslationWithContext": [[1], 2], "markForPluralTranslation": [1, 2] }, "commentTags": [ "Translation:" ] } }, { "extractor": "xml", "filemasks": [ "gui/gamesetup/**.xml", "gui/gamesetup_mp/**.xml", "gui/loading/**.xml" ], "options": { "keywords": { "translatableAttribute": { "locationAttributes": ["id"] }, "translate": {} } } }, { "extractor": "txt", "filemasks": [ "gui/text/quotes.txt" ], "options": { } } ] }, { "output": "public-gui-lobby.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "javascript", "filemasks": [ "gui/lobby/**.js", "gui/prelobby/**.js" ], "options": { "format": "javascript-format", "keywords": { "translate": [1], "translatePlural": [1, 2], "translateWithContext": [[1], 2], "translatePluralWithContext": [[1], 2, 3], "markForTranslation": [1], "markForTranslationWithContext": [[1], 2], "markForPluralTranslation": [1, 2] }, "commentTags": [ "Translation:" ] } }, { "extractor": "xml", "filemasks": [ "gui/lobby/**.xml", "gui/prelobby/**.xml" ], "options": { "keywords": { "translatableAttribute": { "locationAttributes": ["id"] }, "translate": {} } } }, { "extractor": "txt", "filemasks": [ "gui/prelobby/common/terms/*.txt" ], "options": { } } ] }, { "output": "public-gui-manual.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "javascript", "filemasks": [ "gui/manual/**.js" ], "options": { "format": "javascript-format", "keywords": { "translate": [1], "translatePlural": [1, 2], "translateWithContext": [[1], 2], "translatePluralWithContext": [[1], 2, 3], "markForTranslation": [1], "markForTranslationWithContext": [[1], 2], "markForPluralTranslation": [1, 2] }, "commentTags": [ "Translation:" ] } }, { "extractor": "xml", "filemasks": [ "gui/manual/**.xml" ], "options": { "keywords": { "translatableAttribute": { "locationAttributes": ["id"] }, "translate": {} } } }, { "extractor": "txt", "filemasks": [ "gui/manual/intro.txt" ], "options": { } } ] }, { "output": "public-gui-userreport.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "txt", "filemasks": [ "gui/userreport/**.txt" ], "options": { } } ] }, { "output": "public-gui-other.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "javascript", "filemasks": [ "globalscripts/**.js", "gui/common/**.js", "gui/credits/**.js", "gui/loadgame/**.js", "gui/locale/**.js", "gui/options/**.js", "gui/pregame/**.js", "gui/reference/civinfo/**.js", "gui/reference/common/**.js", "gui/reference/structree/**.js", "gui/reference/viewer/**.js", "gui/replaymenu/**.js", "gui/splashscreen/**.js", "gui/summary/**.js" ], "options": { "format": "javascript-format", "keywords": { "translate": [1], "translatePlural": [1, 2], "translateWithContext": [[1], 2], "translatePluralWithContext": [[1], 2, 3], "markForTranslation": [1], "markForTranslationWithContext": [[1], 2], "markForPluralTranslation": [1, 2] }, "commentTags": [ "dennis-ignore:", "Translation:" ] } }, { "extractor": "xml", "filemasks": [ "globalscripts/**.xml", "gui/common/**.xml", "gui/credits/**.xml", "gui/loadgame/**.xml", "gui/locale/**.xml", "gui/options/**.xml", "gui/pregame/**.xml", "gui/reference/civinfo/**.xml", "gui/reference/structree/**.xml", "gui/reference/viewer/**.xml", "gui/replaymenu/**.xml", "gui/splashscreen/**.xml", "gui/summary/**.xml" ], "options": { "keywords": { "translatableAttribute": { "locationAttributes": ["id"] }, "translate": {} } } }, { "extractor": "json", "filemasks": [ "gui/credits/texts/**.json" ], "options": { "keywords": [ "Title", "Subtitle" ] } }, { "extractor": "json", "filemasks": [ "gui/options/**.json" ], "options": { "keywords": [ "label", "tooltip" ] } }, { "extractor": "json", "filemasks": [ "simulation/data/resources/**.json" ], "options": { "keywords": [ "description" ] } }, { "extractor": "json", "filemasks": [ "simulation/data/resources/**.json" ], "options": { "keywords": [ "name", "subtypes" ], "comments": [ "Translation: Word as used at the beginning of a sentence or as a single-word sentence." ], "context": "firstWord" } }, { "extractor": "json", "filemasks": [ "simulation/data/resources/**.json" ], "options": { "keywords": [ "name", "subtypes" ], "comments": [ "Translation: Word as used in the middle of a sentence (which may require using lowercase for your language)." ], "context": "withinSentence" } }, { "extractor": "txt", "filemasks": [ "gui/gamesetup/**.txt", "gui/splashscreen/splashscreen.txt", "gui/text/tips/**.txt" ], "options": { } } ] }, { "output": "public-templates-units.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "xml", "filemasks": [ "simulation/templates/template_unit_*.xml", "simulation/templates/units/**.xml" ], "options": { "keywords": { "AttackName": { "customContext": "Name of an attack, usually the weapon." }, "StatusName": { "customContext": "status effect" }, "ApplierTooltip": { "customContext": "status effect" }, "ReceiverTooltip": { "customContext": "status effect" }, "GenericName": {}, "SpecificName": {}, "History": {}, "VisibleClasses": { "splitOnWhitespace": true }, "Tooltip": {}, "DisabledTooltip": {}, "FormationName": {}, "FromClass": {}, "Rank": { "tagAsContext": true } } } } ] }, { "output": "public-templates-buildings.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "xml", "filemasks": [ "simulation/templates/template_structure_*.xml", "simulation/templates/structures/**.xml" ], "options": { "keywords": { "AttackName": { "customContext": "Name of an attack, usually the weapon." }, "StatusName": { "customContext": "status effect" }, "ApplierTooltip": { "customContext": "status effect" }, "ReceiverTooltip": { "customContext": "status effect" }, "GenericName": {}, "SpecificName": {}, "History": {}, "VisibleClasses": { "splitOnWhitespace": true }, "Tooltip": {}, "DisabledTooltip": {}, "FormationName": {}, "FromClass": {}, "Rank": { "tagAsContext": true } } } } ] }, { "output": "public-templates-other.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "xml", "filemasks": { "includeMasks": [ "simulation/templates/**.xml" ], "excludeMasks": [ "simulation/templates/structures/**.xml", "simulation/templates/template_structure_*.xml", "simulation/templates/template_unit_*.xml", "simulation/templates/units/**.xml" ] }, "options": { "keywords": { "AttackName": { "customContext": "Name of an attack, usually the weapon." }, "GenericName": {}, "SpecificName": {}, "History": {}, "VisibleClasses": { "splitOnWhitespace": true }, "Tooltip": {}, "DisabledTooltip": {}, "FormationName": {}, "FromClass": {}, "Rank": { "tagAsContext": true } } } }, { "extractor": "json", "filemasks": [ "simulation/data/template_helpers/damage_types/*.json" ], "options": { "keywords": [ "name", "description" ], "context": "damage type" } }, { "extractor": "json", "filemasks": [ "simulation/data/status_effects/*.json" ], "options": { "keywords": [ "statusName", "applierTooltip", "receiverTooltip" ], "context": "status effect" } + }, + { + "extractor": "json", + "filemasks": [ + "simulation/data/attack_effects/*.json" + ], + "options": { + "keywords": [ + "name", + "description" + ], + "context": "effect caused by an attack" + } } ] }, { "output": "public-simulation-auras.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "json", "filemasks": [ "simulation/data/auras/**.json" ], "options": { "keywords": [ "auraName", "auraDescription" ] } } ] }, { "output": "public-simulation-technologies.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "json", "filemasks": [ "simulation/data/technologies/**.json" ], "options": { "keywords": [ "specificName", "genericName", "description", "tooltip", "requirementsTooltip" ] } } ] }, { "output": "public-simulation-other.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "javascript", "filemasks": [ "simulation/ai/**.js", "simulation/components/**.js", "simulation/helpers/**.js" ], "options": { "format": "javascript-format", "keywords": { "translate": [1], "translatePlural": [1, 2], "translateWithContext": [[1], 2], "translatePluralWithContext": [[1], 2, 3], "markForTranslation": [1], "markForTranslationWithContext": [[1], 2], "markForPluralTranslation": [1, 2] }, "commentTags": [ "Translation:" ] } }, { "extractor": "json", "filemasks": [ "simulation/data/settings/player_defaults.json" ], "options": { "keywords": [ "Name" ] } }, { "extractor": "json", "filemasks": [ "simulation/data/settings/game_speeds.json" ], "options": { "keywords": ["Title"] } }, { "extractor": "json", "filemasks": [ "simulation/data/settings/victory_conditions/*.json" ], "options": { "keywords": ["Title", "Description"] } }, { "extractor": "json", "filemasks": [ "simulation/data/settings/starting_resources.json" ], "options": { "keywords": ["Title"], "context": "startingResources" } }, { "extractor": "json", "filemasks": [ "simulation/data/settings/trigger_difficulties.json" ], "options": { "keywords": ["Title", "Tooltip"] } }, { "extractor": "json", "filemasks": [ "simulation/data/settings/map_sizes.json" ], "options": { "keywords": [ "Name", "Tooltip" ] } }, { "extractor": "json", "filemasks": [ "simulation/ai/**.json" ], "options": { "keywords": [ "name", "description" ] } } ] }, { "output": "public-maps.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "json", "filemasks": { "includeMasks": [ "maps/random/**.json" ], "excludeMasks": [ "maps/random/rmbiome/**.json" ] }, "options": { "keywords": [ "Name", "Description" ] } }, { "extractor": "javascript", "filemasks": [ "maps/scenarios/**.js", "maps/skirmishes/**.js", "maps/random/**.js" ], "options": { "format": "javascript-format", "keywords": { "markForTranslation": [1], "markForTranslationWithContext": [[1], 2], "markForPluralTranslation": [1, 2] }, "commentTags": [ "Translation:" ] } }, { "extractor": "xml", "filemasks": [ "maps/scenarios/**.xml", "maps/skirmishes/**.xml" ], "options": { "keywords": { "ScriptSettings": { "extractJson": { "keywords": [ "Name", "Description" ] } } } } }, { "extractor": "json", "filemasks": [ "maps/random/rmbiome/**.json" ], "options": { "keywords": ["Description"], "context": "biome definition" } } ] }, { "output": "public-tutorials.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "javascript", "filemasks": [ "maps/tutorials/**.js" ], "options": { "format": "javascript-format", "keywords": { "markForTranslation": [1], "markForTranslationWithContext": [[1], 2], "markForPluralTranslation": [1, 2] }, "commentTags": [ "Translation:" ] } }, { "extractor": "xml", "filemasks": [ "maps/tutorials/**.xml" ], "options": { "keywords": { "ScriptSettings": { "extractJson": { "keywords": [ "Name", "Description" ] } } } } } ] } ] Index: ps/trunk/binaries/data/mods/public/simulation/components/AttackDetection.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/AttackDetection.js (revision 24499) +++ ps/trunk/binaries/data/mods/public/simulation/components/AttackDetection.js (revision 24500) @@ -1,168 +1,165 @@ function AttackDetection() {} AttackDetection.prototype.Schema = "Detects incoming attacks." + "" + "" + "" + "" + "" + "" + "" + "" + "" + ""; AttackDetection.prototype.Init = function() { this.suppressionTime = +this.template.SuppressionTime; // Use squared distance to avoid sqrts this.suppressionTransferRangeSquared = +this.template.SuppressionTransferRange * +this.template.SuppressionTransferRange; this.suppressionRangeSquared = +this.template.SuppressionRange * +this.template.SuppressionRange; this.suppressedList = []; }; AttackDetection.prototype.ActivateTimer = function() { Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer).SetTimeout(this.entity, IID_AttackDetection, "HandleTimeout", this.suppressionTime); }; AttackDetection.prototype.AddSuppression = function(event) { this.suppressedList.push(event); this.ActivateTimer(); }; AttackDetection.prototype.UpdateSuppressionEvent = function(index, event) { this.suppressedList[index] = event; this.ActivateTimer(); }; //// Message handlers //// AttackDetection.prototype.OnGlobalAttacked = function(msg) { var cmpPlayer = Engine.QueryInterface(this.entity, IID_Player); var cmpOwnership = Engine.QueryInterface(msg.target, IID_Ownership); if (cmpOwnership.GetOwner() != cmpPlayer.GetPlayerID()) return; Engine.PostMessage(msg.target, MT_MinimapPing); this.AttackAlert(msg.target, msg.attacker, msg.type, msg.attackerOwner); }; //// External interface //// AttackDetection.prototype.AttackAlert = function(target, attacker, type, attackerOwner) { let playerID = Engine.QueryInterface(this.entity, IID_Player).GetPlayerID(); // Don't register attacks dealt against other players if (Engine.QueryInterface(target, IID_Ownership).GetOwner() != playerID) return; let cmpAttackerOwnership = Engine.QueryInterface(attacker, IID_Ownership); let atkOwner = cmpAttackerOwnership && cmpAttackerOwnership.GetOwner() != INVALID_PLAYER ? cmpAttackerOwnership.GetOwner() : attackerOwner; // Don't register attacks dealt by myself if (atkOwner == playerID) return; // Since livestock can be attacked/gathered by other players // and generally are not so valuable as other units/buildings, // we have a lower priority notification for it, which can be // overriden by a regular one. var cmpTargetIdentity = Engine.QueryInterface(target, IID_Identity); var targetIsDomesticAnimal = cmpTargetIdentity && cmpTargetIdentity.HasClass("Animal") && cmpTargetIdentity.HasClass("Domestic"); var cmpPosition = Engine.QueryInterface(target, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return; var event = { "target": target, "position": cmpPosition.GetPosition(), "time": Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer).GetTime(), "targetIsDomesticAnimal": targetIsDomesticAnimal }; // If we already have a low priority livestock event in suppressed list, // and now a more important target is attacked, we want to upgrade the // suppressed event and send the new notification var isPriorityIncreased = false; for (var i = 0; i < this.suppressedList.length; ++i) { var element = this.suppressedList[i]; // If the new attack is within suppression distance of this element, // then check if the element should be updated and return var dist = event.position.horizDistanceToSquared(element.position); if (dist >= this.suppressionRangeSquared) continue; isPriorityIncreased = element.targetIsDomesticAnimal && !targetIsDomesticAnimal; var isPriorityDescreased = !element.targetIsDomesticAnimal && targetIsDomesticAnimal; if (isPriorityIncreased || (!isPriorityDescreased && dist < this.suppressionTransferRangeSquared)) this.UpdateSuppressionEvent(i, event); // If priority has increased, exit the loop to send the upgraded notification below if (isPriorityIncreased) break; return; } // If priority has increased for an existing event, then we already have it // in the suppression list if (!isPriorityIncreased) this.AddSuppression(event); Engine.PostMessage(this.entity, MT_AttackDetected, { "player": playerID, "event": event }); Engine.QueryInterface(SYSTEM_ENTITY, IID_GuiInterface).PushNotification({ "type": "attack", "target": target, "players": [playerID], "attacker": atkOwner, "targetIsDomesticAnimal": targetIsDomesticAnimal }); - let soundGroup = "attacked"; - if (g_EffectReceiver[type] && g_EffectReceiver[type].sound) - soundGroup += '_' + g_EffectReceiver[type].sound; - + let soundGroup = g_AttackEffects.GetSound(type); if (attackerOwner === 0) soundGroup += "_gaia"; PlaySound(soundGroup, target); }; AttackDetection.prototype.GetSuppressionTime = function() { return this.suppressionTime; }; AttackDetection.prototype.HandleTimeout = function() { var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); var now = cmpTimer.GetTime(); for (var i = 0; i < this.suppressedList.length; ++i) { var event = this.suppressedList[i]; // Check if this event has timed out if (now - event.time >= this.suppressionTime) { this.suppressedList.splice(i, 1); return; } } }; AttackDetection.prototype.GetIncomingAttacks = function() { return this.suppressedList; }; Engine.RegisterComponentType(IID_AttackDetection, "AttackDetection", AttackDetection); Index: ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Attack.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Attack.js (revision 24499) +++ ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Attack.js (revision 24500) @@ -1,336 +1,359 @@ +AttackEffects = class AttackEffects +{ + constructor() {} + Receivers() + { + return [{ + "type": "Damage", + "IID": "IID_Health", + "method": "TakeDamage" + }, + { + "type": "Capture", + "IID": "IID_Capturable", + "method": "Capture" + }, + { + "type": "ApplyStatus", + "IID": "IID_StatusEffectsReceiver", + "method": "ApplyStatus" + }]; + } +}; + Engine.LoadHelperScript("Attacking.js"); Engine.LoadHelperScript("Player.js"); Engine.LoadHelperScript("ValueModification.js"); Engine.LoadComponentScript("interfaces/Auras.js"); Engine.LoadComponentScript("interfaces/Capturable.js"); Engine.LoadComponentScript("interfaces/ModifiersManager.js"); Engine.LoadComponentScript("interfaces/Formation.js"); Engine.LoadComponentScript("interfaces/Health.js"); Engine.LoadComponentScript("interfaces/TechnologyManager.js"); Engine.LoadComponentScript("Attack.js"); let entityID = 903; function attackComponentTest(defenderClass, isEnemy, test_function) { let playerEnt1 = 5; AddMock(SYSTEM_ENTITY, IID_PlayerManager, { "GetPlayerByID": () => playerEnt1 }); AddMock(playerEnt1, IID_Player, { "GetPlayerID": () => 1, "IsEnemy": () => isEnemy }); let attacker = entityID; AddMock(attacker, IID_Position, { "IsInWorld": () => true, "GetHeightOffset": () => 5, "GetPosition2D": () => new Vector2D(1, 2) }); AddMock(attacker, IID_Ownership, { "GetOwner": () => 1 }); let cmpAttack = ConstructComponent(attacker, "Attack", { "Melee": { "Damage": { "Hack": 11, "Pierce": 5, "Crush": 0 }, "MinRange": 3, "MaxRange": 5, "PreferredClasses": { "_string": "FemaleCitizen" }, "RestrictedClasses": { "_string": "Elephant Archer" }, "Bonuses": { "BonusCav": { "Classes": "Cavalry", "Multiplier": 2 } } }, "Ranged": { "Damage": { "Hack": 0, "Pierce": 10, "Crush": 0 }, "MinRange": 10, "MaxRange": 80, "PrepareTime": 300, "RepeatTime": 500, "Projectile": { "Speed": 10, "Spread": 2, "Gravity": 1, "FriendlyFire": "false" }, "PreferredClasses": { "_string": "Archer" }, "RestrictedClasses": { "_string": "Elephant" }, "Splash": { "Shape": "Circular", "Range": 10, "FriendlyFire": "false", "Damage": { "Hack": 0.0, "Pierce": 15.0, "Crush": 35.0 }, "Bonuses": { "BonusCav": { "Classes": "Cavalry", "Multiplier": 3 } } } }, "Capture": { "Capture": 8, "MaxRange": 10, }, "Slaughter": {}, "StatusEffect": { "ApplyStatus": { "StatusInternalName": { "StatusName": "StatusShownName", "ApplierTooltip": "ApplierTooltip", "ReceiverTooltip": "ReceiverTooltip", "Duration": 5000, "Stackability": "Stacks", "Modifiers": { "SE": { "Paths": { "_string": "Health/Max" }, "Affects": { "_string": "Unit" }, "Add": 10 } } } }, "MinRange": "10", "MaxRange": "80" } }); let defender = ++entityID; AddMock(defender, IID_Identity, { "GetClassesList": () => [defenderClass], "HasClass": className => className == defenderClass, "GetCiv": () => "civ" }); AddMock(defender, IID_Ownership, { "GetOwner": () => 1 }); AddMock(defender, IID_Position, { "IsInWorld": () => true, "GetHeightOffset": () => 0 }); AddMock(defender, IID_Health, { "GetHitpoints": () => 100 }); test_function(attacker, cmpAttack, defender); } // 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(["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.GetPreferredClasses("Melee"), ["FemaleCitizen"]); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetRestrictedClasses("Melee"), ["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"), { "Damage": { "Hack": 0, "Pierce": 10, "Crush": 0 } }); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackEffectsData("Ranged", true), { "Damage": { "Hack": 0.0, "Pierce": 15.0, "Crush": 35.0 }, "Bonuses": { "BonusCav": { "Classes": "Cavalry", "Multiplier": 3 } } }); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackEffectsData("StatusEffect"), { "ApplyStatus": { "StatusInternalName": { "Duration": 5000, "Interval": 0, "Stackability": "Stacks", "Modifiers": { "SE": { "Paths": { "_string": "Health/Max" }, "Affects": { "_string": "Unit" }, "Add": 10 } } } } }); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetTimers("Ranged"), { "prepare": 300, "repeat": 500 }); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetRepeatTime("Ranged"), 500); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetTimers("Capture"), { "prepare": 0, "repeat": 1000 }); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetRepeatTime("Capture"), 1000); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetSplashData("Ranged"), { "attackData": { "Damage": { "Hack": 0, "Pierce": 15, "Crush": 35, }, "Bonuses": { "BonusCav": { "Classes": "Cavalry", "Multiplier": 3 } } }, "friendlyFire": false, "radius": 10, "shape": "Circular" }); }); 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("Capture").Bonuses || null, null); let getAttackBonus = (s, t, e, splash) => Attacking.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, "Capture", defender), 1); TS_ASSERT_UNEVAL_EQUALS(getAttackBonus(attacker, "Slaughter", defender), 1); }); // CanAttack rejects elephant attack due to RestrictedClasses attackComponentTest("Elephant", true, (attacker, cmpAttack, defender) => { TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender), false); }); function testGetBestAttackAgainst(defenderClass, bestAttack, bestAllyAttack, isBuilding = false) { attackComponentTest(defenderClass, true, (attacker, cmpAttack, defender) => { if (isBuilding) AddMock(defender, IID_Capturable, { "CanCapture": playerID => { TS_ASSERT_EQUALS(playerID, 1); return true; } }); 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); }); attackComponentTest(defenderClass, false, (attacker, cmpAttack, defender) => { if (isBuilding) AddMock(defender, IID_Capturable, { "CanCapture": playerID => { TS_ASSERT_EQUALS(playerID, 1); return true; } }); 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); }); } testGetBestAttackAgainst("FemaleCitizen", "Melee", undefined); testGetBestAttackAgainst("Archer", "Ranged", undefined); testGetBestAttackAgainst("Domestic", "Slaughter", "Slaughter"); testGetBestAttackAgainst("Structure", "Capture", "Capture", true); testGetBestAttackAgainst("Structure", "Ranged", undefined, false); Index: ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Damage.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Damage.js (revision 24499) +++ ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Damage.js (revision 24500) @@ -1,692 +1,705 @@ +AttackEffects = class AttackEffects +{ + constructor() {} + Receivers() + { + return [{ + "type": "Damage", + "IID": "IID_Health", + "method": "TakeDamage" + }]; + } +}; + Engine.LoadHelperScript("Attacking.js"); Engine.LoadHelperScript("Player.js"); Engine.LoadHelperScript("Position.js"); Engine.LoadHelperScript("ValueModification.js"); Engine.LoadComponentScript("interfaces/DelayedDamage.js"); Engine.LoadComponentScript("interfaces/Health.js"); Engine.LoadComponentScript("interfaces/Loot.js"); Engine.LoadComponentScript("interfaces/Promotion.js"); Engine.LoadComponentScript("interfaces/ModifiersManager.js"); Engine.LoadComponentScript("interfaces/Resistance.js"); Engine.LoadComponentScript("interfaces/Timer.js"); Engine.LoadComponentScript("Attack.js"); Engine.LoadComponentScript("DelayedDamage.js"); Engine.LoadComponentScript("Timer.js"); function Test_Generic() { ResetState(); let cmpTimer = ConstructComponent(SYSTEM_ENTITY, "Timer"); cmpTimer.OnUpdate({ "turnLength": 1 }); let attacker = 11; let atkPlayerEntity = 1; let attackerOwner = 6; let cmpAttack = ConstructComponent(attacker, "Attack", { "Ranged": { "Damage": { "Crush": 5, }, "MaxRange": 50, "MinRange": 0, "Delay": 0, "Projectile": { "Speed": 75.0, "Spread": 0.5, "Gravity": 9.81, "FriendlyFire": "false", "LaunchPoint": { "@y": 3 } } } }); let damage = 5; let target = 21; let targetOwner = 7; let targetPos = new Vector3D(3, 0, 3); let type = "Melee"; let damageTaken = false; cmpAttack.GetAttackStrengths = attackType => ({ "Hack": 0, "Pierce": 0, "Crush": damage }); let data = { "type": "Melee", "attackData": { "Damage": { "Hack": 0, "Pierce": 0, "Crush": damage }, }, "target": target, "attacker": attacker, "attackerOwner": attackerOwner, "position": targetPos, "projectileId": 9, "direction": new Vector3D(1, 0, 0) }; AddMock(atkPlayerEntity, IID_Player, { "GetEnemies": () => [targetOwner] }); AddMock(SYSTEM_ENTITY, IID_PlayerManager, { "GetPlayerByID": id => atkPlayerEntity, "GetAllPlayers": () => [0, 1, 2, 3, 4] }); AddMock(SYSTEM_ENTITY, IID_ProjectileManager, { "RemoveProjectile": () => {}, "LaunchProjectileAtPoint": (ent, pos, speed, gravity) => {}, }); AddMock(target, IID_Position, { "GetPosition": () => targetPos, "GetPreviousPosition": () => targetPos, "GetPosition2D": () => Vector2D.From(targetPos), "IsInWorld": () => true, }); AddMock(target, IID_Health, { "TakeDamage": (amount, __, ___) => { damageTaken = true; return { "healthChange": -amount }; }, }); AddMock(SYSTEM_ENTITY, IID_DelayedDamage, { "MissileHit": () => { damageTaken = true; }, }); Engine.PostMessage = function(ent, iid, message) { TS_ASSERT_UNEVAL_EQUALS({ "type": type, "target": target, "attacker": attacker, "attackerOwner": attackerOwner, "damage": damage, "capture": 0, "statusEffects": [], "fromStatusEffect": false }, message); }; AddMock(target, IID_Footprint, { "GetShape": () => ({ "type": "circle", "radius": 20 }), }); AddMock(attacker, IID_Ownership, { "GetOwner": () => attackerOwner, }); AddMock(attacker, IID_Position, { "GetPosition": () => new Vector3D(2, 0, 3), "GetRotation": () => new Vector3D(1, 2, 3), "IsInWorld": () => true, }); function TestDamage() { cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT(damageTaken); damageTaken = false; } Attacking.HandleAttackEffects(target, data.type, data.attackData, data.attacker, data.attackerOwner); TestDamage(); data.type = "Ranged"; type = data.type; Attacking.HandleAttackEffects(target, data.type, data.attackData, data.attacker, data.attackerOwner); TestDamage(); // Check for damage still being dealt if the attacker dies cmpAttack.PerformAttack("Ranged", target); Engine.DestroyEntity(attacker); TestDamage(); atkPlayerEntity = 1; AddMock(atkPlayerEntity, IID_Player, { "GetEnemies": () => [2, 3] }); TS_ASSERT_UNEVAL_EQUALS(Attacking.GetPlayersToDamage(atkPlayerEntity, true), [0, 1, 2, 3, 4]); TS_ASSERT_UNEVAL_EQUALS(Attacking.GetPlayersToDamage(atkPlayerEntity, false), [2, 3]); } Test_Generic(); function TestLinearSplashDamage() { ResetState(); Engine.PostMessage = (ent, iid, message) => {}; const attacker = 50; const attackerOwner = 1; const origin = new Vector2D(0, 0); let data = { "type": "Ranged", "attackData": { "Damage": { "Hack": 100, "Pierce": 0, "Crush": 0 } }, "attacker": attacker, "attackerOwner": attackerOwner, "origin": origin, "radius": 10, "shape": "Linear", "direction": new Vector3D(1, 747, 0), "friendlyFire": false, }; let fallOff = function(x, y) { return (1 - x * x / (data.radius * data.radius)) * (1 - 25 * y * y / (data.radius * data.radius)); }; let hitEnts = new Set(); AddMock(attackerOwner, IID_Player, { "GetEnemies": () => [2] }); AddMock(SYSTEM_ENTITY, IID_PlayerManager, { "GetPlayerByID": id => attackerOwner, "GetAllPlayers": () => [0, 1, 2] }); AddMock(SYSTEM_ENTITY, IID_RangeManager, { "ExecuteQueryAroundPos": () => [60, 61, 62], }); AddMock(SYSTEM_ENTITY, IID_ObstructionManager, { "DistanceToPoint": (ent) => ({ "60": Math.sqrt(9.25), "61": 0, "62": Math.sqrt(29) }[ent]) }); AddMock(60, IID_Position, { "GetPosition2D": () => new Vector2D(3, -0.5), }); AddMock(61, IID_Position, { "GetPosition2D": () => new Vector2D(0, 0), }); AddMock(62, IID_Position, { "GetPosition2D": () => new Vector2D(5, 2), }); AddMock(60, IID_Health, { "TakeDamage": (amount, __, ___) => { hitEnts.add(60); TS_ASSERT_EQUALS(amount, 100 * fallOff(3, -0.5)); return { "healthChange": -amount }; } }); AddMock(61, IID_Health, { "TakeDamage": (amount, __, ___) => { hitEnts.add(61); TS_ASSERT_EQUALS(amount, 100 * fallOff(0, 0)); return { "healthChange": -amount }; } }); AddMock(62, IID_Health, { "TakeDamage": (amount, __, ___) => { hitEnts.add(62); // Minor numerical precision issues make this necessary TS_ASSERT(amount < 0.00001); return { "healthChange": -amount }; } }); Attacking.CauseDamageOverArea(data); TS_ASSERT(hitEnts.has(60)); TS_ASSERT(hitEnts.has(61)); TS_ASSERT(hitEnts.has(62)); hitEnts.clear(); data.direction = new Vector3D(0.6, 747, 0.8); AddMock(60, IID_Health, { "TakeDamage": (amount, __, ___) => { hitEnts.add(60); TS_ASSERT_EQUALS(amount, 100 * fallOff(1, 2)); return { "healthChange": -amount }; } }); Attacking.CauseDamageOverArea(data); TS_ASSERT(hitEnts.has(60)); TS_ASSERT(hitEnts.has(61)); TS_ASSERT(hitEnts.has(62)); hitEnts.clear(); } TestLinearSplashDamage(); function TestCircularSplashDamage() { ResetState(); Engine.PostMessage = (ent, iid, message) => {}; const radius = 10; let attackerOwner = 1; let fallOff = function(r) { return 1 - r * r / (radius * radius); }; AddMock(attackerOwner, IID_Player, { "GetEnemies": () => [2] }); AddMock(SYSTEM_ENTITY, IID_PlayerManager, { "GetPlayerByID": id => attackerOwner, "GetAllPlayers": () => [0, 1, 2] }); AddMock(SYSTEM_ENTITY, IID_RangeManager, { "ExecuteQueryAroundPos": () => [60, 61, 62, 64, 65], }); AddMock(SYSTEM_ENTITY, IID_ObstructionManager, { "DistanceToPoint": (ent, x, z) => ({ "60": 0, "61": 5, "62": 1, "63": Math.sqrt(85), "64": 10, "65": 2 }[ent]) }); AddMock(60, IID_Position, { "GetPosition2D": () => new Vector2D(3, 4), }); AddMock(61, IID_Position, { "GetPosition2D": () => new Vector2D(0, 0), }); AddMock(62, IID_Position, { "GetPosition2D": () => new Vector2D(3.6, 3.2), }); AddMock(63, IID_Position, { "GetPosition2D": () => new Vector2D(10, -10), }); // Target on the frontier of the shape (see distance above). AddMock(64, IID_Position, { "GetPosition2D": () => new Vector2D(9, -4), }); // Big target far away (see distance above). AddMock(65, IID_Position, { "GetPosition2D": () => new Vector2D(23, 4), }); AddMock(60, IID_Health, { "TakeDamage": (amount, __, ___) => { TS_ASSERT_EQUALS(amount, 100 * fallOff(0)); return { "healthChange": -amount }; } }); AddMock(61, IID_Health, { "TakeDamage": (amount, __, ___) => { TS_ASSERT_EQUALS(amount, 100 * fallOff(5)); return { "healthChange": -amount }; } }); AddMock(62, IID_Health, { "TakeDamage": (amount, __, ___) => { TS_ASSERT_EQUALS(amount, 100 * fallOff(1)); return { "healthChange": -amount }; } }); AddMock(63, IID_Health, { "TakeDamage": (amount, __, ___) => { TS_ASSERT(false); } }); let cmphealth64 = AddMock(64, IID_Health, { "TakeDamage": (amount, __, ___) => { TS_ASSERT_EQUALS(amount, 0); return { "healthChange": -amount }; } }); let spy64 = new Spy(cmphealth64, "TakeDamage"); let cmpHealth65 = AddMock(65, IID_Health, { "TakeDamage": (amount, __, ___) => { TS_ASSERT_EQUALS(amount, 100 * fallOff(2)); return { "healthChange": -amount }; } }); let spy65 = new Spy(cmpHealth65, "TakeDamage"); Attacking.CauseDamageOverArea({ "type": "Ranged", "attackData": { "Damage": { "Hack": 100, "Pierce": 0, "Crush": 0 } }, "attacker": 50, "attackerOwner": attackerOwner, "origin": new Vector2D(3, 4), "radius": radius, "shape": "Circular", "friendlyFire": false, }); TS_ASSERT_EQUALS(spy64._called, 1); TS_ASSERT_EQUALS(spy65._called, 1); } TestCircularSplashDamage(); function Test_MissileHit() { ResetState(); Engine.PostMessage = (ent, iid, message) => {}; let cmpDelayedDamage = ConstructComponent(SYSTEM_ENTITY, "DelayedDamage"); let target = 60; let targetOwner = 1; let targetPos = new Vector3D(3, 10, 0); let hitEnts = new Set(); AddMock(SYSTEM_ENTITY, IID_Timer, { "GetLatestTurnLength": () => 500 }); const radius = 10; let data = { "type": "Ranged", "attackData": { "Damage": { "Hack": 0, "Pierce": 100, "Crush": 0 } }, "target": 60, "attacker": 70, "attackerOwner": 1, "position": targetPos, "direction": new Vector3D(1, 0, 0), "projectileId": 9, "friendlyFire": "false", }; AddMock(SYSTEM_ENTITY, IID_PlayerManager, { "GetPlayerByID": id => id == 1 ? 10 : 11, "GetAllPlayers": () => [0, 1] }); AddMock(SYSTEM_ENTITY, IID_ProjectileManager, { "RemoveProjectile": () => {}, "LaunchProjectileAtPoint": (ent, pos, speed, gravity) => {}, }); AddMock(60, IID_Position, { "GetPosition": () => targetPos, "GetPreviousPosition": () => targetPos, "GetPosition2D": () => Vector2D.From(targetPos), "IsInWorld": () => true, }); AddMock(60, IID_Health, { "TakeDamage": (amount, __, ___) => { hitEnts.add(60); TS_ASSERT_EQUALS(amount, 100); return { "healthChange": -amount }; } }); AddMock(60, IID_Footprint, { "GetShape": () => ({ "type": "circle", "radius": 20 }), }); AddMock(70, IID_Ownership, { "GetOwner": () => 1, }); AddMock(70, IID_Position, { "GetPosition": () => new Vector3D(0, 0, 0), "GetRotation": () => new Vector3D(0, 0, 0), "IsInWorld": () => true, }); AddMock(10, IID_Player, { "GetEnemies": () => [2] }); cmpDelayedDamage.MissileHit(data, 0); TS_ASSERT(hitEnts.has(60)); hitEnts.clear(); // Target is a mirage: hit the parent. AddMock(60, IID_Mirage, { "GetParent": () => 61 }); AddMock(SYSTEM_ENTITY, IID_ObstructionManager, { "DistanceToPoint": (ent) => 0 }); AddMock(61, IID_Position, { "GetPosition": () => targetPos, "GetPreviousPosition": () => targetPos, "GetPosition2D": () => Vector2D.from3D(targetPos), "IsInWorld": () => true }); AddMock(61, IID_Health, { "TakeDamage": (amount, __, ___) => { hitEnts.add(61); TS_ASSERT_EQUALS(amount, 100); return { "healthChange": -amount }; } }); AddMock(61, IID_Footprint, { "GetShape": () => ({ "type": "circle", "radius": 20 }) }); cmpDelayedDamage.MissileHit(data, 0); TS_ASSERT(hitEnts.has(61)); hitEnts.clear(); // Make sure we don't corrupt other tests. DeleteMock(60, IID_Mirage); cmpDelayedDamage.MissileHit(data, 0); TS_ASSERT(hitEnts.has(60)); hitEnts.clear(); // The main target is not hit but another one is hit. AddMock(60, IID_Position, { "GetPosition": () => new Vector3D(900, 10, 0), "GetPreviousPosition": () => new Vector3D(900, 10, 0), "GetPosition2D": () => new Vector2D(900, 0), "IsInWorld": () => true }); AddMock(60, IID_Health, { "TakeDamage": (amount, __, ___) => { TS_ASSERT_EQUALS(false); return { "healthChange": -amount }; } }); AddMock(SYSTEM_ENTITY, IID_RangeManager, { "ExecuteQueryAroundPos": () => [61] }); cmpDelayedDamage.MissileHit(data, 0); TS_ASSERT(hitEnts.has(61)); hitEnts.clear(); // Add a splash damage. data.splash = {}; data.splash.friendlyFire = false; data.splash.radius = 10; data.splash.shape = "Circular"; data.splash.attackData = { "Damage": { "Hack": 0, "Pierce": 0, "Crush": 200 } }; AddMock(SYSTEM_ENTITY, IID_RangeManager, { "ExecuteQueryAroundPos": () => [61, 62] }); AddMock(SYSTEM_ENTITY, IID_ObstructionManager, { "DistanceToPoint": (ent) => ({ "61": 0, "62": 5 }[ent]) }); let dealtDamage = 0; AddMock(61, IID_Health, { "TakeDamage": (amount, __, ___) => { hitEnts.add(61); dealtDamage += amount; return { "healthChange": -amount }; } }); AddMock(62, IID_Position, { "GetPosition": () => new Vector3D(8, 10, 0), "GetPreviousPosition": () => new Vector3D(8, 10, 0), "GetPosition2D": () => new Vector2D(8, 0), "IsInWorld": () => true, }); AddMock(62, IID_Health, { "TakeDamage": (amount, __, ___) => { hitEnts.add(62); TS_ASSERT_EQUALS(amount, 200 * 0.75); return { "healthChange": -amount }; } }); AddMock(62, IID_Footprint, { "GetShape": () => ({ "type": "circle", "radius": 20 }), }); cmpDelayedDamage.MissileHit(data, 0); TS_ASSERT(hitEnts.has(61)); TS_ASSERT_EQUALS(dealtDamage, 100 + 200); dealtDamage = 0; TS_ASSERT(hitEnts.has(62)); hitEnts.clear(); // Add some hard counters bonus. Engine.DestroyEntity(62); AddMock(SYSTEM_ENTITY, IID_RangeManager, { "ExecuteQueryAroundPos": () => [61] }); AddMock(SYSTEM_ENTITY, IID_ObstructionManager, { "DistanceToPoint": (ent) => 0 }); let bonus= { "BonusCav": { "Classes": "Cavalry", "Multiplier": 400 } }; let splashBonus = { "BonusCav": { "Classes": "Cavalry", "Multiplier": 10000 } }; AddMock(61, IID_Identity, { "GetClassesList": () => ["Cavalry"], "GetCiv": () => "civ" }); data.attackData.Bonuses = bonus; cmpDelayedDamage.MissileHit(data, 0); TS_ASSERT(hitEnts.has(61)); TS_ASSERT_EQUALS(dealtDamage, 400 * 100 + 200); dealtDamage = 0; hitEnts.clear(); data.splash.attackData.Bonuses = splashBonus; cmpDelayedDamage.MissileHit(data, 0); TS_ASSERT(hitEnts.has(61)); TS_ASSERT_EQUALS(dealtDamage, 400 * 100 + 10000 * 200); dealtDamage = 0; hitEnts.clear(); data.attackData.Bonuses = undefined; cmpDelayedDamage.MissileHit(data, 0); TS_ASSERT(hitEnts.has(61)); TS_ASSERT_EQUALS(dealtDamage, 100 + 10000 * 200); dealtDamage = 0; hitEnts.clear(); data.attackData.Bonuses = null; cmpDelayedDamage.MissileHit(data, 0); TS_ASSERT(hitEnts.has(61)); TS_ASSERT_EQUALS(dealtDamage, 100 + 10000 * 200); dealtDamage = 0; hitEnts.clear(); data.attackData.Bonuses = {}; cmpDelayedDamage.MissileHit(data, 0); TS_ASSERT(hitEnts.has(61)); TS_ASSERT_EQUALS(dealtDamage, 100 + 10000 * 200); dealtDamage = 0; hitEnts.clear(); // Test splash damage with friendly fire. data.splash = {}; data.splash.friendlyFire = true; data.splash.radius = 10; data.splash.shape = "Circular"; data.splash.attackData = { "Damage": { "Pierce": 0, "Crush": 200 } }; AddMock(SYSTEM_ENTITY, IID_RangeManager, { "ExecuteQueryAroundPos": () => [61, 62] }); AddMock(SYSTEM_ENTITY, IID_ObstructionManager, { "DistanceToPoint": (ent) => ({ "61": 0, "62": 5 }[ent]) }); dealtDamage = 0; AddMock(61, IID_Health, { "TakeDamage": (amount, __, ___) => { hitEnts.add(61); dealtDamage += amount; return { "healthChange": -amount }; } }); AddMock(62, IID_Position, { "GetPosition": () => new Vector3D(8, 10, 0), "GetPreviousPosition": () => new Vector3D(8, 10, 0), "GetPosition2D": () => new Vector2D(8, 0), "IsInWorld": () => true, }); AddMock(62, IID_Health, { "TakeDamage": (amount, __, ___) => { hitEnts.add(62); TS_ASSERT_EQUALS(amount, 200 * 0.75); return { "healtChange": -amount }; } }); AddMock(62, IID_Footprint, { "GetShape": () => ({ "type": "circle", "radius": 20 }), }); cmpDelayedDamage.MissileHit(data, 0); TS_ASSERT(hitEnts.has(61)); TS_ASSERT_EQUALS(dealtDamage, 100 + 200); dealtDamage = 0; TS_ASSERT(hitEnts.has(62)); hitEnts.clear(); } Test_MissileHit(); Index: ps/trunk/binaries/data/mods/public/simulation/components/tests/test_DeathDamage.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/tests/test_DeathDamage.js (revision 24499) +++ ps/trunk/binaries/data/mods/public/simulation/components/tests/test_DeathDamage.js (revision 24500) @@ -1,73 +1,77 @@ +AttackEffects = class AttackEffects +{ +}; + Engine.LoadHelperScript("Attacking.js"); Engine.LoadHelperScript("ValueModification.js"); Engine.LoadComponentScript("interfaces/DeathDamage.js"); Engine.LoadComponentScript("interfaces/ModifiersManager.js"); Engine.LoadComponentScript("DeathDamage.js"); let deadEnt = 60; let player = 1; ApplyValueModificationsToEntity = function(value, stat, ent) { if (value == "DeathDamage/Damage/Pierce" && ent == deadEnt) return stat + 200; return stat; }; let template = { "Shape": "Circular", "Range": 10.7, "FriendlyFire": "false", "Damage": { "Hack": 0.0, "Pierce": 15.0, "Crush": 35.0 } }; let effects = { "Damage": { "Hack": 0.0, "Pierce": 215.0, "Crush": 35.0 } }; let cmpDeathDamage = ConstructComponent(deadEnt, "DeathDamage", template); let playersToDamage = [2, 3, 7]; let pos = new Vector2D(3, 4.2); let result = { "type": "Death", "attackData": effects, "attacker": deadEnt, "attackerOwner": player, "origin": pos, "radius": template.Range, "shape": template.Shape, "friendlyFire": false }; Attacking.CauseDamageOverArea = data => TS_ASSERT_UNEVAL_EQUALS(data, result); Attacking.GetPlayersToDamage = () => playersToDamage; AddMock(deadEnt, IID_Position, { "GetPosition2D": () => pos, "IsInWorld": () => true }); AddMock(deadEnt, IID_Ownership, { "GetOwner": () => player }); TS_ASSERT_UNEVAL_EQUALS(cmpDeathDamage.GetDeathDamageEffects(), effects); cmpDeathDamage.CauseDeathDamage(); // Test splash damage bonus effects.Bonuses = { "BonusCav": { "Classes": "Cavalry", "Multiplier": 3 } }; template.Bonuses = effects.Bonuses; cmpDeathDamage = ConstructComponent(deadEnt, "DeathDamage", template); result.attackData.Bonuses = effects.Bonuses; TS_ASSERT_UNEVAL_EQUALS(cmpDeathDamage.GetDeathDamageEffects(), effects); cmpDeathDamage.CauseDeathDamage(); Index: ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Resistance.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Resistance.js (revision 24499) +++ ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Resistance.js (revision 24500) @@ -1,350 +1,373 @@ +AttackEffects = class AttackEffects +{ + constructor() {} + Receivers() + { + return [{ + "type": "Damage", + "IID": "IID_Health", + "method": "TakeDamage" + }, + { + "type": "Capture", + "IID": "IID_Capturable", + "method": "Capture" + }, + { + "type": "ApplyStatus", + "IID": "IID_StatusEffectsReceiver", + "method": "ApplyStatus" + }]; + } +}; + Engine.LoadHelperScript("Attacking.js"); Engine.LoadHelperScript("Player.js"); Engine.LoadHelperScript("ValueModification.js"); Engine.LoadComponentScript("interfaces/Capturable.js"); Engine.LoadComponentScript("interfaces/Foundation.js"); Engine.LoadComponentScript("interfaces/Health.js"); Engine.LoadComponentScript("interfaces/Looter.js"); Engine.LoadComponentScript("interfaces/ModifiersManager.js"); Engine.LoadComponentScript("interfaces/PlayerManager.js"); Engine.LoadComponentScript("interfaces/Promotion.js"); Engine.LoadComponentScript("interfaces/Resistance.js"); Engine.LoadComponentScript("interfaces/StatisticsTracker.js"); Engine.LoadComponentScript("interfaces/StatusEffectsReceiver.js"); Engine.LoadComponentScript("Resistance.js"); class testResistance { constructor() { this.cmpResistance = null; this.PlayerID = 1; this.EnemyID = 2; this.EntityID = 3; this.AttackerID = 4; } Reset(schema = {}) { this.cmpResistance = ConstructComponent(this.EntityID, "Resistance", schema); DeleteMock(this.EntityID, IID_Capturable); DeleteMock(this.EntityID, IID_Health); DeleteMock(this.EntityID, IID_Identity); DeleteMock(this.EntityID, IID_StatusEffectsReceiver); } TestInvulnerability() { this.Reset(); let damage = 5; let attackData = { "Damage": { "Name": damage } }; let attackType = "Test"; TS_ASSERT(!this.cmpResistance.IsInvulnerable()); let cmpHealth = AddMock(this.EntityID, IID_Health, { "TakeDamage": (amount, __, ___) => { TS_ASSERT_EQUALS(amount, damage); return { "healthChange": -amount }; } }); let spy = new Spy(cmpHealth, "TakeDamage"); Attacking.HandleAttackEffects(this.EntityID, attackType, attackData, this.AttackerID, this.EnemyID); TS_ASSERT_EQUALS(spy._called, 1); this.cmpResistance.SetInvulnerability(true); TS_ASSERT(this.cmpResistance.IsInvulnerable()); Attacking.HandleAttackEffects(this.EntityID, attackType, attackData, this.AttackerID, this.EnemyID); TS_ASSERT_EQUALS(spy._called, 1); } TestBonus() { this.Reset(); let damage = 5; let bonus = 2; let classes = "Entity"; let attackData = { "Damage": { "Name": damage }, "Bonuses": { "bonus": { "Classes": classes, "Multiplier": bonus } } }; AddMock(this.EntityID, IID_Identity, { "GetClassesList": () => [classes], "GetCiv": () => "civ" }); let cmpHealth = AddMock(this.EntityID, IID_Health, { "TakeDamage": (amount, __, ___) => { TS_ASSERT_EQUALS(amount, damage * bonus); return { "healthChange": -amount }; } }); let spy = new Spy(cmpHealth, "TakeDamage"); Attacking.HandleAttackEffects(this.EntityID, "Test", attackData, this.AttackerID, this.EnemyID); TS_ASSERT_EQUALS(spy._called, 1); } TestDamageResistanceApplies() { let resistanceValue = 2; let damageType = "Name"; this.Reset({ "Entity": { "Damage": { [damageType]: resistanceValue } } }); let damage = 5; let attackData = { "Damage": { "Name": damage } }; let cmpHealth = AddMock(this.EntityID, IID_Health, { "TakeDamage": (amount, __, ___) => { TS_ASSERT_EQUALS(amount, damage * Math.pow(0.9, resistanceValue)); return { "healthChange": -amount }; } }); let spy = new Spy(cmpHealth, "TakeDamage"); Attacking.HandleAttackEffects(this.EntityID, "Test", attackData, this.AttackerID, this.EnemyID); TS_ASSERT_EQUALS(spy._called, 1); } TestCaptureResistanceApplies() { let resistanceValue = 2; this.Reset({ "Entity": { "Capture": resistanceValue } }); let damage = 5; let attackData = { "Capture": damage }; let cmpCapturable = AddMock(this.EntityID, IID_Capturable, { "Capture": (amount, __, ___) => { TS_ASSERT_EQUALS(amount, damage * Math.pow(0.9, resistanceValue)); return { "captureChange": amount }; } }); let spy = new Spy(cmpCapturable, "Capture"); Attacking.HandleAttackEffects(this.EntityID, "Test", attackData, this.AttackerID, this.EnemyID); TS_ASSERT_EQUALS(spy._called, 1); } TestStatusEffectsResistancesApplies() { // Test duration reduction. let durationFactor = 0.5; let statusName = "statusName"; this.Reset({ "Entity": { "ApplyStatus": { [statusName]: { "Duration": durationFactor } } } }); let duration = 10; let attackData = { "ApplyStatus": { [statusName]: { "Duration": duration } } }; let cmpStatusEffectsReceiver = AddMock(this.EntityID, IID_StatusEffectsReceiver, { "ApplyStatus": (effectData, __, ___) => { TS_ASSERT_EQUALS(effectData[statusName].Duration, duration * durationFactor); return { "inflictedStatuses": Object.keys(effectData) }; } }); let spy = new Spy(cmpStatusEffectsReceiver, "ApplyStatus"); Attacking.HandleAttackEffects(this.EntityID, "Test", attackData, this.AttackerID, this.EnemyID); TS_ASSERT_EQUALS(spy._called, 1); // Test blocking. this.Reset({ "Entity": { "ApplyStatus": { [statusName]: { "BlockChance": "1" } } } }); cmpStatusEffectsReceiver = AddMock(this.EntityID, IID_StatusEffectsReceiver, { "ApplyStatus": (effectData, __, ___) => { TS_ASSERT_UNEVAL_EQUALS(effectData, {}); return { "inflictedStatuses": Object.keys(effectData) }; } }); spy = new Spy(cmpStatusEffectsReceiver, "ApplyStatus"); Attacking.HandleAttackEffects(this.EntityID, "Test", attackData, this.AttackerID, this.EnemyID); TS_ASSERT_EQUALS(spy._called, 1); // Test multiple resistances. let reducedStatusName = "reducedStatus"; let blockedStatusName = "blockedStatus"; this.Reset({ "Entity": { "ApplyStatus": { [reducedStatusName]: { "Duration": durationFactor }, [blockedStatusName]: { "BlockChance": "1" } } } }); attackData = { "ApplyStatus": { [reducedStatusName]: { "Duration": duration }, [blockedStatusName]: { "Duration": duration } } }; cmpStatusEffectsReceiver = AddMock(this.EntityID, IID_StatusEffectsReceiver, { "ApplyStatus": (effectData, __, ___) => { TS_ASSERT_EQUALS(effectData[reducedStatusName].Duration, duration * durationFactor); TS_ASSERT_UNEVAL_EQUALS(Object.keys(effectData), [reducedStatusName]); return { "inflictedStatuses": Object.keys(effectData) }; } }); spy = new Spy(cmpStatusEffectsReceiver, "ApplyStatus"); Attacking.HandleAttackEffects(this.EntityID, "Test", attackData, this.AttackerID, this.EnemyID); TS_ASSERT_EQUALS(spy._called, 1); } TestResistanceAndBonus() { let resistanceValue = 2; let damageType = "Name"; this.Reset({ "Entity": { "Damage": { [damageType]: resistanceValue } } }); let damage = 5; let bonus = 2; let classes = "Entity"; let attackData = { "Damage": { "Name": damage }, "Bonuses": { "bonus": { "Classes": classes, "Multiplier": bonus } } }; AddMock(this.EntityID, IID_Identity, { "GetClassesList": () => [classes], "GetCiv": () => "civ" }); let cmpHealth = AddMock(this.EntityID, IID_Health, { "TakeDamage": (amount, __, ___) => { TS_ASSERT_EQUALS(amount, damage * bonus * Math.pow(0.9, resistanceValue)); return { "healthChange": -amount }; } }); let spy = new Spy(cmpHealth, "TakeDamage"); Attacking.HandleAttackEffects(this.EntityID, "Test", attackData, this.AttackerID, this.EnemyID); TS_ASSERT_EQUALS(spy._called, 1); } TestMultipleEffects() { let captureResistanceValue = 2; this.Reset({ "Entity": { "Capture": captureResistanceValue } }); let damage = 5; let bonus = 2; let classes = "Entity"; let attackData = { "Damage": { "Name": damage }, "Capture": damage, "Bonuses": { "bonus": { "Classes": classes, "Multiplier": bonus } } }; AddMock(this.EntityID, IID_Identity, { "GetClassesList": () => [classes], "GetCiv": () => "civ" }); let cmpCapturable = AddMock(this.EntityID, IID_Capturable, { "Capture": (amount, __, ___) => { TS_ASSERT_EQUALS(amount, damage * bonus * Math.pow(0.9, captureResistanceValue)); return { "captureChange": amount }; } }); let cmpHealth = AddMock(this.EntityID, IID_Health, { "TakeDamage": (amount, __, ___) => { TS_ASSERT_EQUALS(amount, damage * bonus); return { "healthChange": -amount }; }, "GetHitpoints": () => 1, "GetMaxHitpoints": () => 1 }); let healthSpy = new Spy(cmpHealth, "TakeDamage"); let captureSpy = new Spy(cmpCapturable, "Capture"); Attacking.HandleAttackEffects(this.EntityID, "Test", attackData, this.AttackerID, this.EnemyID); TS_ASSERT_EQUALS(healthSpy._called, 1); TS_ASSERT_EQUALS(captureSpy._called, 1); } } let cmp = new testResistance(); cmp.TestInvulnerability(); cmp.TestBonus(); cmp.TestDamageResistanceApplies(); cmp.TestCaptureResistanceApplies(); cmp.TestStatusEffectsResistancesApplies(); cmp.TestResistanceAndBonus(); cmp.TestMultipleEffects(); Index: ps/trunk/binaries/data/mods/public/simulation/data/attack_effects/applystatus.json =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/data/attack_effects/applystatus.json (nonexistent) +++ ps/trunk/binaries/data/mods/public/simulation/data/attack_effects/applystatus.json (revision 24500) @@ -0,0 +1,9 @@ +{ + "code": "ApplyStatus", + "description": "Various (timed) effects.", + "IID": "IID_StatusEffectsReceiver", + "method": "ApplyStatus", + "name": "Apply Status", + "order": 3, + "sound": "attacked" +} Property changes on: ps/trunk/binaries/data/mods/public/simulation/data/attack_effects/applystatus.json ___________________________________________________________________ Added: svn:eol-style ## -0,0 +1 ## +native \ No newline at end of property Added: svn:mime-type ## -0,0 +1 ## +text/json \ No newline at end of property Index: ps/trunk/binaries/data/mods/public/simulation/data/attack_effects/capture.json =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/data/attack_effects/capture.json (nonexistent) +++ ps/trunk/binaries/data/mods/public/simulation/data/attack_effects/capture.json (revision 24500) @@ -0,0 +1,9 @@ +{ + "code": "Capture", + "description": "Reduces capture points of a target.", + "IID": "IID_Capturable", + "method": "Capture", + "name": "Capture", + "order": 2, + "sound": "attacked_capture" +} Property changes on: ps/trunk/binaries/data/mods/public/simulation/data/attack_effects/capture.json ___________________________________________________________________ Added: svn:eol-style ## -0,0 +1 ## +native \ No newline at end of property Added: svn:mime-type ## -0,0 +1 ## +text/json \ No newline at end of property Index: ps/trunk/binaries/data/mods/public/simulation/data/attack_effects/damage.json =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/data/attack_effects/damage.json (nonexistent) +++ ps/trunk/binaries/data/mods/public/simulation/data/attack_effects/damage.json (revision 24500) @@ -0,0 +1,9 @@ +{ + "code": "Damage", + "description": "Reduces the health of a target.", + "IID": "IID_Health", + "method": "TakeDamage", + "name": "Damage", + "order": 1, + "sound": "attacked" +} Property changes on: ps/trunk/binaries/data/mods/public/simulation/data/attack_effects/damage.json ___________________________________________________________________ Added: svn:eol-style ## -0,0 +1 ## +native \ No newline at end of property Added: svn:mime-type ## -0,0 +1 ## +text/json \ No newline at end of property Index: ps/trunk/binaries/data/mods/public/simulation/helpers/Attacking.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/helpers/Attacking.js (revision 24499) +++ ps/trunk/binaries/data/mods/public/simulation/helpers/Attacking.js (revision 24500) @@ -1,380 +1,381 @@ /** * Provides attack and damage-related helpers under the Attacking umbrella (to avoid name ambiguity with the component). */ function Attacking() {} const DirectEffectsSchema = "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + ""; const StatusEffectsSchema = "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + DirectEffectsSchema + "" + "" + "" + "" + "" + ModificationsSchema + "" + "" + "" + "Ignore" + "Extend" + "Replace" + "Stack" + "" + "" + "" + "" + "" + ""; /** * Builds a RelaxRNG schema of possible attack effects. * See globalscripts/AttackEffects.js for possible elements. * Attacks may also have a "Bonuses" element. * * @return {string} - RelaxNG schema string. */ Attacking.prototype.BuildAttackEffectsSchema = function() { return "" + "" + "" + DirectEffectsSchema + StatusEffectsSchema + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + ""; }; /** * Returns a template-like object of attack effects. */ Attacking.prototype.GetAttackEffectsData = function(valueModifRoot, template, entity) { let ret = {}; if (template.Damage) { ret.Damage = {}; let applyMods = damageType => ApplyValueModificationsToEntity(valueModifRoot + "/Damage/" + damageType, +(template.Damage[damageType] || 0), entity); for (let damageType in template.Damage) ret.Damage[damageType] = applyMods(damageType); } if (template.Capture) ret.Capture = ApplyValueModificationsToEntity(valueModifRoot + "/Capture", +(template.Capture || 0), entity); if (template.ApplyStatus) ret.ApplyStatus = this.GetStatusEffectsData(valueModifRoot, template.ApplyStatus, entity); if (template.Bonuses) ret.Bonuses = template.Bonuses; return ret; }; Attacking.prototype.GetStatusEffectsData = function(valueModifRoot, template, entity) { let result = {}; for (let effect in template) { let statusTemplate = template[effect]; result[effect] = { "Duration": ApplyValueModificationsToEntity(valueModifRoot + "/ApplyStatus/" + effect + "/Duration", +(statusTemplate.Duration || 0), entity), "Interval": ApplyValueModificationsToEntity(valueModifRoot + "/ApplyStatus/" + effect + "/Interval", +(statusTemplate.Interval || 0), entity), "Stackability": statusTemplate.Stackability }; Object.assign(result[effect], this.GetAttackEffectsData(valueModifRoot + "/ApplyStatus" + effect, statusTemplate, entity)); if (statusTemplate.Modifiers) result[effect].Modifiers = this.GetStatusEffectsModifications(valueModifRoot, statusTemplate.Modifiers, entity, effect); } return result; }; Attacking.prototype.GetStatusEffectsModifications = function(valueModifRoot, template, entity, effect) { let modifiers = {}; for (let modifier in template) { let modifierTemplate = template[modifier]; modifiers[modifier] = { "Paths": modifierTemplate.Paths, "Affects": modifierTemplate.Affects }; if (modifierTemplate.Add !== undefined) modifiers[modifier].Add = ApplyValueModificationsToEntity(valueModifRoot + "/ApplyStatus/" + effect + "/Modifiers/" + modifier + "/Add", +modifierTemplate.Add, entity); if (modifierTemplate.Multiply !== undefined) modifiers[modifier].Multiply = ApplyValueModificationsToEntity(valueModifRoot + "/ApplyStatus/" + effect + "/Modifiers/" + modifier + "/Multiply", +modifierTemplate.Multiply, entity); if (modifierTemplate.Replace !== undefined) modifiers[modifier].Replace = modifierTemplate.Replace; } return modifiers; }; /** * Calculate the total effect taking bonus and resistance into account. * * @param {number} target - The target of the attack. * @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 {Object} cmpResistance - Optionally the resistance component of the target. * * @return {number} - The total value of the effect. */ Attacking.prototype.GetTotalAttackEffects = function(target, effectData, effectType, bonusMultiplier, cmpResistance) { let total = 0; if (!cmpResistance) cmpResistance = Engine.QueryInterface(target, IID_Resistance); let resistanceStrengths = cmpResistance ? cmpResistance.GetEffectiveResistanceAgainst(effectType) : {}; if (effectType == "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") { 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); if (cmpHealth) total /= 0.1 + 0.9 * cmpHealth.GetHitpoints() / cmpHealth.GetMaxHitpoints(); } if (effectType != "ApplyStatus") return total * bonusMultiplier; if (!resistanceStrengths.ApplyStatus) return effectData[effectType]; let result = {}; for (let statusEffect in effectData[effectType]) { if (!resistanceStrengths.ApplyStatus[statusEffect]) { result[statusEffect] = effectData[effectType][statusEffect]; continue; } if (randBool(resistanceStrengths.ApplyStatus[statusEffect].blockChance)) continue; result[statusEffect] = effectData[effectType][statusEffect]; if (effectData[effectType][statusEffect].Duration) result[statusEffect].Duration = effectData[effectType][statusEffect].Duration * resistanceStrengths.ApplyStatus[statusEffect].duration; } return result; }; /** * Get the list of players affected by the damage. * @param {number} attackerOwner - The player id of the attacker. * @param {boolean} friendlyFire - A flag indicating if allied entities are also damaged. * @return {number[]} The ids of players need to be damaged. */ Attacking.prototype.GetPlayersToDamage = function(attackerOwner, friendlyFire) { if (!friendlyFire) return QueryPlayerIDInterface(attackerOwner).GetEnemies(); return Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetAllPlayers(); }; /** * Damages units around a given origin. * @param {Object} data - The data sent by the caller. * @param {string} data.type - The type of damage. * @param {Object} data.attackData - The attack data. * @param {number} data.attacker - The entity id of the attacker. * @param {number} data.attackerOwner - The player id of the attacker. * @param {Vector2D} data.origin - The origin of the projectile hit. * @param {number} data.radius - The radius of the splash damage. * @param {string} data.shape - The shape of the radius. * @param {Vector3D} [data.direction] - The unit vector defining the direction. Needed for linear splash damage. * @param {boolean} data.friendlyFire - A flag indicating if allied entities also ought to be damaged. */ Attacking.prototype.CauseDamageOverArea = function(data) { let nearEnts = PositionHelper.EntitiesNearPoint(data.origin, data.radius, this.GetPlayersToDamage(data.attackerOwner, data.friendlyFire)); let damageMultiplier = 1; let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); // Cycle through all the nearby entities and damage it appropriately based on its distance from the origin. for (let ent of nearEnts) { // Correct somewhat for the entity's obstruction radius. // TODO: linear falloff should arguably use something cleverer. let distance = cmpObstructionManager.DistanceToPoint(ent, data.origin.x, data.origin.y); if (data.shape == 'Circular') // circular effect with quadratic falloff in every direction damageMultiplier = 1 - distance * distance / (data.radius * data.radius); else if (data.shape == 'Linear') // linear effect with quadratic falloff in two directions (only used for certain missiles) { // The entity has a position here since it was returned by the range manager. let entityPosition = Engine.QueryInterface(ent, IID_Position).GetPosition2D(); let relativePos = entityPosition.sub(data.origin).normalize().mult(distance); // Get the position relative to the missile direction. let direction = Vector2D.from3D(data.direction); let parallelPos = relativePos.dot(direction); let perpPos = relativePos.cross(direction); // The width of linear splash is one fifth of the normal splash radius. let width = data.radius / 5; // Check that the unit is within the distance splash width of the line starting at the missile's // landing point which extends in the direction of the missile for length splash radius. if (parallelPos >= 0 && Math.abs(perpPos) < width) // If in radius, quadratic falloff in both directions damageMultiplier = (1 - parallelPos * parallelPos / (data.radius * data.radius)) * (1 - perpPos * perpPos / (width * width)); else damageMultiplier = 0; } else // In case someone calls this function with an invalid shape. { warn("The " + data.shape + " splash damage shape is not implemented!"); } // The RangeManager can return units that are too far away (due to approximations there) // so the multiplier can end up below 0. damageMultiplier = Math.max(0, damageMultiplier); this.HandleAttackEffects(ent, data.type + ".Splash", data.attackData, data.attacker, data.attackerOwner, damageMultiplier); } }; /** * Handle an attack peformed on an entity. * * @param {number} target - The targetted entityID. * @param {string} attackType - The type of attack that was performed (e.g. "Melee" or "Capture"). * @param {Object} effectData - The effects use. * @param {number} attacker - The entityID that attacked us. * @param {number} attackerOwner - The playerID that owned the attacker when the attack was performed. * @param {number} bonusMultiplier - The factor to multiply the total effect with, defaults to 1. * * @return {boolean} - Whether we handled the attack. */ Attacking.prototype.HandleAttackEffects = function(target, attackType, attackData, attacker, attackerOwner, bonusMultiplier = 1) { let cmpResistance = Engine.QueryInterface(target, IID_Resistance); if (cmpResistance && cmpResistance.IsInvulnerable()) return false; bonusMultiplier *= !attackData.Bonuses ? 1 : this.GetAttackBonus(attacker, target, attackType, attackData.Bonuses); let targetState = {}; - for (let effectType of g_EffectTypes) + for (let receiver of g_AttackEffects.Receivers()) { - if (!attackData[effectType]) + if (!attackData[receiver.type]) continue; - let receiver = g_EffectReceiver[effectType]; let cmpReceiver = Engine.QueryInterface(target, global[receiver.IID]); if (!cmpReceiver) continue; - Object.assign(targetState, cmpReceiver[receiver.method](this.GetTotalAttackEffects(target, attackData, effectType, bonusMultiplier, cmpResistance), attacker, attackerOwner)); + Object.assign(targetState, cmpReceiver[receiver.method](this.GetTotalAttackEffects(target, attackData, receiver.type, bonusMultiplier, cmpResistance), attacker, attackerOwner)); } if (!Object.keys(targetState).length) return false; Engine.PostMessage(target, MT_Attacked, { "type": attackType, "target": target, "attacker": attacker, "attackerOwner": attackerOwner, "damage": -(targetState.healthChange || 0), "capture": targetState.captureChange || 0, "statusEffects": targetState.inflictedStatuses || [], "fromStatusEffect": !!attackData.StatusEffect, }); // We do not want an entity to get XP from active Status Effects. if (!!attackData.StatusEffect) return true; let cmpPromotion = Engine.QueryInterface(attacker, IID_Promotion); if (cmpPromotion && targetState.xp) cmpPromotion.IncreaseXp(targetState.xp); return true; }; /** * Calculates the attack damage multiplier against a target. * @param {number} source - The source entity's id. * @param {number} target - The target entity's id. * @param {string} type - The type of attack. * @param {Object} template - The bonus' template. * @return {number} - The source entity's attack bonus against the specified target. */ Attacking.prototype.GetAttackBonus = function(source, target, type, template) { let cmpIdentity = Engine.QueryInterface(target, IID_Identity); if (!cmpIdentity) return 1; let attackBonus = 1; let targetClasses = cmpIdentity.GetClassesList(); let targetCiv = cmpIdentity.GetCiv(); // Multiply the bonuses for all matching classes. for (let key in template) { let bonus = template[key]; if (bonus.Civ && bonus.Civ !== targetCiv) continue; if (!bonus.Classes || MatchesClassList(targetClasses, bonus.Classes)) attackBonus *= ApplyValueModificationsToEntity("Attack/" + type + "/Bonuses/" + key + "/Multiplier", +bonus.Multiplier, source); } return attackBonus; }; var AttackingInstance = new Attacking(); Engine.RegisterGlobal("Attacking", AttackingInstance); + +Engine.RegisterGlobal("g_AttackEffects", new AttackEffects()); Index: ps/trunk/binaries/data/mods/public/simulation/helpers/tests/test_Attacking.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/helpers/tests/test_Attacking.js (revision 24499) +++ ps/trunk/binaries/data/mods/public/simulation/helpers/tests/test_Attacking.js (revision 24500) @@ -1,136 +1,159 @@ +AttackEffects = class AttackEffects +{ + constructor() {} + Receivers() + { + return [{ + "type": "Damage", + "IID": "IID_Health", + "method": "TakeDamage" + }, + { + "type": "Capture", + "IID": "IID_Capturable", + "method": "Capture" + }, + { + "type": "ApplyStatus", + "IID": "IID_StatusEffectsReceiver", + "method": "ApplyStatus" + }]; + } +}; + Engine.LoadHelperScript("Attacking.js"); Engine.LoadComponentScript("interfaces/Capturable.js"); Engine.LoadComponentScript("interfaces/Health.js"); Engine.LoadComponentScript("interfaces/Promotion.js"); Engine.LoadComponentScript("interfaces/Resistance.js"); Engine.LoadComponentScript("interfaces/StatusEffectsReceiver.js"); // Unit tests for the Attacking helper. // TODO: Some of it is tested in components/test_Damage.js, which should be spliced and moved. class testHandleAttackEffects { constructor() { this.resultString = ""; this.TESTED_ENTITY_ID = 5; this.attackData = { "Damage": "1", "Capture": "2", "ApplyStatus": { "statusName": {} } }; } /** * This tests that we inflict multiple effect types. */ testMultipleEffects() { AddMock(this.TESTED_ENTITY_ID, IID_Health, { "TakeDamage": x => { this.resultString += x; }, "GetHitpoints": () => 1, "GetMaxHitpoints": () => 1, }); AddMock(this.TESTED_ENTITY_ID, IID_Capturable, { "Capture": x => { this.resultString += x; }, }); Attacking.HandleAttackEffects(this.TESTED_ENTITY_ID, "Test", this.attackData, INVALID_ENTITY, INVALID_PLAYER); TS_ASSERT(this.resultString.indexOf(this.attackData.Damage) !== -1); TS_ASSERT(this.resultString.indexOf(this.attackData.Capture) !== -1); } /** * This tests that we correctly handle effect types if one is not received. */ testSkippedEffect() { AddMock(this.TESTED_ENTITY_ID, IID_Capturable, { "Capture": x => { this.resultString += x; }, }); Attacking.HandleAttackEffects(this.TESTED_ENTITY_ID, "Test", this.attackData, INVALID_ENTITY, INVALID_PLAYER); TS_ASSERT(this.resultString.indexOf(this.attackData.Damage) === -1); TS_ASSERT(this.resultString.indexOf(this.attackData.Capture) !== -1); this.resultString = ""; DeleteMock(this.TESTED_ENTITY_ID, IID_Capturable); AddMock(this.TESTED_ENTITY_ID, IID_Health, { "TakeDamage": x => { this.resultString += x; }, "GetHitpoints": () => 1, "GetMaxHitpoints": () => 1, }); Attacking.HandleAttackEffects(this.TESTED_ENTITY_ID, "Test", this.attackData, INVALID_ENTITY, INVALID_PLAYER); TS_ASSERT(this.resultString.indexOf(this.attackData.Damage) !== -1); TS_ASSERT(this.resultString.indexOf(this.attackData.Capture) === -1); } /** * Check that the Attacked message is [not] sent if [no] receivers exist. */ testAttackedMessage() { Engine.PostMessage = () => TS_ASSERT(false); Attacking.HandleAttackEffects(this.TESTED_ENTITY_ID, "Test", this.attackData, INVALID_ENTITY, INVALID_PLAYER); AddMock(this.TESTED_ENTITY_ID, IID_Capturable, { "Capture": () => ({ "captureChange": 0 }), }); let count = 0; Engine.PostMessage = () => count++; Attacking.HandleAttackEffects(this.TESTED_ENTITY_ID, "Test", this.attackData, INVALID_ENTITY, INVALID_PLAYER); TS_ASSERT_EQUALS(count, 1); AddMock(this.TESTED_ENTITY_ID, IID_Health, { "TakeDamage": () => ({ "healthChange": 0 }), "GetHitpoints": () => 1, "GetMaxHitpoints": () => 1, }); count = 0; Engine.PostMessage = () => count++; Attacking.HandleAttackEffects(this.TESTED_ENTITY_ID, "Test", this.attackData, INVALID_ENTITY, INVALID_PLAYER); TS_ASSERT_EQUALS(count, 1); } /** * Regression test that StatusEffects are handled correctly. */ testStatusEffects() { let cmpStatusEffectsReceiver = AddMock(this.TESTED_ENTITY_ID, IID_StatusEffectsReceiver, { "ApplyStatus": (effectData, __, ___) => { TS_ASSERT_UNEVAL_EQUALS(effectData, this.attackData.ApplyStatus); } }); let spy = new Spy(cmpStatusEffectsReceiver, "ApplyStatus"); Attacking.HandleAttackEffects(this.TESTED_ENTITY_ID, "Test", this.attackData, INVALID_ENTITY, INVALID_PLAYER, 2); TS_ASSERT_EQUALS(spy._called, 1); } /** * Regression test that bonus multiplier is handled correctly. */ testBonusMultiplier() { AddMock(this.TESTED_ENTITY_ID, IID_Health, { "TakeDamage": (amount, __, ___) => { TS_ASSERT_EQUALS(amount, this.attackData.Damage * 2); }, "GetHitpoints": () => 1, "GetMaxHitpoints": () => 1, }); AddMock(this.TESTED_ENTITY_ID, IID_Capturable, { "Capture": (amount, __, ___) => { TS_ASSERT_EQUALS(amount, this.attackData.Capture * 2); }, }); Attacking.HandleAttackEffects(this.TESTED_ENTITY_ID, "Test", this.attackData, INVALID_ENTITY, INVALID_PLAYER, 2); } } new testHandleAttackEffects().testMultipleEffects(); new testHandleAttackEffects().testSkippedEffect(); new testHandleAttackEffects().testAttackedMessage(); new testHandleAttackEffects().testStatusEffects(); new testHandleAttackEffects().testBonusMultiplier();