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("Attack.js"); Engine.LoadHelperScript("Player.js"); Engine.LoadHelperScript("ValueModification.js"); Engine.LoadComponentScript("interfaces/Auras.js"); Engine.LoadComponentScript("interfaces/Capturable.js"); Engine.LoadComponentScript("interfaces/Diplomacy.js"); Engine.LoadComponentScript("interfaces/ModifiersManager.js"); Engine.LoadComponentScript("interfaces/Formation.js"); Engine.LoadComponentScript("interfaces/Health.js"); Engine.LoadComponentScript("interfaces/Resistance.js"); Engine.LoadComponentScript("interfaces/TechnologyManager.js"); Engine.LoadComponentScript("interfaces/Turretable.js"); Engine.LoadComponentScript("interfaces/TurretHolder.js"); Engine.LoadComponentScript("Attack.js"); let entityID = 903; function attackComponentTest(defenderClass, isEnemy, test_function) { const playerEnt1 = 5; AddMock(SYSTEM_ENTITY, IID_PlayerManager, { "GetPlayerByID": () => playerEnt1 }); AddMock(playerEnt1, IID_Player, { "GetPlayerID": () => 1, }); AddMock(playerEnt1, IID_Diplomacy, { "IsEnemy": () => isEnemy }); AddMock(SYSTEM_ENTITY, IID_ObstructionManager, { "IsInTargetRange": () => true }); AddMock(SYSTEM_ENTITY, IID_RangeManager, { "GetEffectiveParabolicRange": () => 25, "GetMaxReachableParabolicHeight": () => 15, "GetLosVisibility": (target, owner) => "visible" }); const attacker = entityID; AddMock(attacker, IID_Position, { "IsInWorld": () => true, "GetHeightOffset": () => 5, "GetPosition2D": () => new Vector2D(1, 2) }); AddMock(attacker, IID_Ownership, { "GetOwner": () => 1 }); const cmpAttack = ConstructComponent(attacker, "Attack", { "Melee": { "Damage": { "Hack": 11, "Pierce": 5, "Crush": 0 }, "MinRange": 3, "MaxRange": 5, "PreferredClasses": { "_string": "Civilian" }, "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" } }); const 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 }); AddMock(defender, IID_Resistance, { }); 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"), ["Civilian"]); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetRestrictedClasses("Melee"), ["Elephant", "Archer"]); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetFullAttackRange(), { "min": 0, "max": 80, "parabolic": true }); 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 (const 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); const 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, "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); const allowCapturing = [true]; if (!isBuilding) allowCapturing.push(false); for (const 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); const allowCapturing = [true]; if (!isBuilding) allowCapturing.push(false); for (const ac of allowCapturing) TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, ac), bestAllyAttack); }); } testGetBestAttackAgainst("Civilian", "Melee", undefined); testGetBestAttackAgainst("Archer", "Ranged", undefined); testGetBestAttackAgainst("Domestic", "Slaughter", "Slaughter"); testGetBestAttackAgainst("Structure", "Capture", "Capture", true); testGetBestAttackAgainst("Structure", "Ranged", undefined, false); function testAttackPreference() { const attacker = 5; const cmpAttack = ConstructComponent(attacker, "Attack", { "Melee": { "Damage": { "Crush": 0 }, "MinRange": 3, "MaxRange": 5, "PreferredClasses": { "_string": "Civilian Unit+!Ship" }, "RestrictedClasses": { "_string": "Elephant Archer" }, } }); AddMock(attacker+1, IID_Identity, { "GetClassesList": () => ["Civilian", "Unit"] }); AddMock(attacker+2, IID_Identity, { "GetClassesList": () => ["Unit"] }); AddMock(attacker+3, IID_Identity, { "GetClassesList": () => ["Unit", "Ship"] }); AddMock(attacker+4, IID_Identity, { "GetClassesList": () => ["SomethingElse"] }); TS_ASSERT_EQUALS(cmpAttack.GetPreference(attacker+1), 0); TS_ASSERT_EQUALS(cmpAttack.GetPreference(attacker+2), 1); TS_ASSERT_EQUALS(cmpAttack.GetPreference(attacker+3), undefined); TS_ASSERT_EQUALS(cmpAttack.GetPreference(attacker+4), undefined); } testAttackPreference(); function testCanEverReachTarget() { const attacker = ++entityID; AddMock(attacker, IID_Position, { "IsInWorld": () => true, "GetHeightOffset": () => 0, "GetPosition2D": () => new Vector2D(1, 2) }); const cmpAttack = ConstructComponent(attacker, "Attack", { "Melee": { "Damage": { "Hack": 10, "Pierce": 0, "Crush": 0 }, "MaxRange": 5 }, "Ranged": { "Damage": { "Hack": 0, "Pierce": 10, "Crush": 0 }, "MaxRange": 30, "Projectile": { "Speed": 50, "Spread": 1, "Gravity": 1, "FriendlyFire": "false" } } }); // Melee target within 3D range { const defender = ++entityID; AddMock(defender, IID_Position, { "IsInWorld": () => true, "GetHeightOffset": () => 0 }); TS_ASSERT_EQUALS(cmpAttack.CanEverReachTarget(defender, "Melee"), true); } // Melee target too high { const defender = ++entityID; AddMock(defender, IID_Position, { "IsInWorld": () => true, "GetHeightOffset": () => 10 }); TS_ASSERT_EQUALS(cmpAttack.CanEverReachTarget(defender, "Melee"), false); } // Melee target at same height, within range (close distance) { const defender = ++entityID; AddMock(defender, IID_Position, { "IsInWorld": () => true, "GetHeightOffset": () => 4 }); // sqrt(0² + 4²) = 4 <= 5 TS_ASSERT_EQUALS(cmpAttack.CanEverReachTarget(defender, "Melee"), true); } // Ranged: target at same height — reachable from current position (check 1) { const defender = ++entityID; AddMock(defender, IID_Position, { "IsInWorld": () => true, "GetHeightOffset": () => 0, "GetPosition": () => new Vector3D(1, 0, 2) }); // Need RangeManager mock for IsTargetInRange (check 1) to work AddMock(SYSTEM_ENTITY, IID_RangeManager, { "GetEffectiveParabolicRange": () => 25, "GetMaxReachableParabolicHeight": () => 15 }); AddMock(SYSTEM_ENTITY, IID_ObstructionManager, { "IsInTargetRange": () => true }); TS_ASSERT_EQUALS(cmpAttack.CanEverReachTarget(defender, "Ranged"), true); } // Ranged: target too high for parabolic arc even at closest approach (check 2) { const defender = ++entityID; AddMock(defender, IID_Position, { "IsInWorld": () => true, "GetHeightOffset": () => 20, "GetPosition": () => new Vector3D(1, 20, 2) }); AddMock(SYSTEM_ENTITY, IID_RangeManager, { "GetEffectiveParabolicRange": () => -1, // out of range "GetMaxReachableParabolicHeight": () => 10 }); AddMock(SYSTEM_ENTITY, IID_ObstructionManager, { "IsInTargetRange": () => false }); // heightDiff = 20 - 0 = 20, maxReachableHeightDiff = 10 → unreachable TS_ASSERT_EQUALS(cmpAttack.CanEverReachTarget(defender, "Ranged"), false); } } testCanEverReachTarget();