Index: ps/trunk/binaries/data/mods/public/simulation/components/Attack.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/Attack.js (revision 22768) +++ ps/trunk/binaries/data/mods/public/simulation/components/Attack.js (revision 22769) @@ -1,611 +1,615 @@ function Attack() {} var g_AttackTypes = ["Melee", "Ranged", "Capture"]; Attack.prototype.preferredClassesSchema = "" + "" + "" + "tokens" + "" + "" + "" + ""; Attack.prototype.restrictedClassesSchema = "" + "" + "" + "tokens" + "" + "" + "" + ""; Attack.prototype.Schema = "Controls the attack abilities and strengths of the unit." + "" + "" + "" + "10.0" + "0.0" + "5.0" + "" + "4.0" + "1000" + "" + "" + "pers" + "Infantry" + "1.5" + "" + "" + "Cavalry Melee" + "1.5" + "" + "" + "Champion" + "Cavalry Infantry" + "" + "" + "" + "0.0" + "10.0" + "0.0" + "" + "44.0" + "20.0" + "15.0" + "800" + "1600" + "1000" + "" + "" + "Cavalry" + "2" + "" + "" + "" + "50.0" + "2.5" + "props/units/weapons/rock_flaming.xml" + "props/units/weapons/rock_explosion.xml" + "0.1" + "" + "Champion" + "" + "Circular" + "20" + "false" + "" + "0.0" + "10.0" + "0.0" + "" + "" + "" + "" + "" + "1000.0" + "0.0" + "0.0" + "" + "4.0" + "" + "" + "" + "" + "" + Attacking.BuildAttackEffectsSchema() + "" + "" + "" + "" + "" + // TODO: it shouldn't be stretched "" + "" + Attack.prototype.preferredClassesSchema + Attack.prototype.restrictedClassesSchema + "" + "" + "" + "" + "" + "" + Attacking.BuildAttackEffectsSchema() + "" + "" + ""+ "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + Attacking.BuildAttackEffectsSchema() + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + Attack.prototype.preferredClassesSchema + Attack.prototype.restrictedClassesSchema + "" + "" + "" + "" + "" + "" + Attacking.BuildAttackEffectsSchema() + "" + "" + // TODO: it shouldn't be stretched "" + "" + Attack.prototype.preferredClassesSchema + Attack.prototype.restrictedClassesSchema + "" + "" + "" + "" + "" + "" + Attacking.BuildAttackEffectsSchema() + "" + // TODO: how do these work? Attack.prototype.preferredClassesSchema + Attack.prototype.restrictedClassesSchema + "" + "" + ""; Attack.prototype.Init = function() { }; Attack.prototype.Serialize = null; // we have no dynamic state to save Attack.prototype.GetAttackTypes = function(wantedTypes) { let types = g_AttackTypes.filter(type => !!this.template[type]); if (!wantedTypes) return types; let wantedTypesReal = wantedTypes.filter(wtype => wtype.indexOf("!") != 0); return types.filter(type => wantedTypes.indexOf("!" + type) == -1 && (!wantedTypesReal || !wantedTypesReal.length || wantedTypesReal.indexOf(type) != -1)); }; Attack.prototype.GetPreferredClasses = function(type) { if (this.template[type] && this.template[type].PreferredClasses && this.template[type].PreferredClasses._string) return this.template[type].PreferredClasses._string.split(/\s+/); return []; }; Attack.prototype.GetRestrictedClasses = function(type) { if (this.template[type] && this.template[type].RestrictedClasses && this.template[type].RestrictedClasses._string) return this.template[type].RestrictedClasses._string.split(/\s+/); return []; }; Attack.prototype.CanAttack = function(target, wantedTypes) { let cmpFormation = Engine.QueryInterface(target, IID_Formation); if (cmpFormation) return true; let cmpThisPosition = Engine.QueryInterface(this.entity, IID_Position); let cmpTargetPosition = Engine.QueryInterface(target, IID_Position); if (!cmpThisPosition || !cmpTargetPosition || !cmpThisPosition.IsInWorld() || !cmpTargetPosition.IsInWorld()) return false; let cmpIdentity = QueryMiragedInterface(target, IID_Identity); if (!cmpIdentity) return false; let cmpHealth = QueryMiragedInterface(target, IID_Health); let targetClasses = cmpIdentity.GetClassesList(); if (targetClasses.indexOf("Domestic") != -1 && this.template.Slaughter && cmpHealth && cmpHealth.GetHitpoints() && (!wantedTypes || !wantedTypes.filter(wType => wType.indexOf("!") != 0).length)) return true; let cmpEntityPlayer = QueryOwnerInterface(this.entity); let cmpTargetPlayer = QueryOwnerInterface(target); if (!cmpTargetPlayer || !cmpEntityPlayer) return false; let types = this.GetAttackTypes(wantedTypes); let entityOwner = cmpEntityPlayer.GetPlayerID(); let targetOwner = cmpTargetPlayer.GetPlayerID(); let cmpCapturable = QueryMiragedInterface(target, IID_Capturable); // Check if the relative height difference is larger than the attack range // If the relative height is bigger, it means they will never be able to // reach each other, no matter how close they come. let heightDiff = Math.abs(cmpThisPosition.GetHeightOffset() - cmpTargetPosition.GetHeightOffset()); for (let type of types) { if (type != "Capture" && (!cmpEntityPlayer.IsEnemy(targetOwner) || !cmpHealth || !cmpHealth.GetHitpoints())) continue; if (type == "Capture" && (!cmpCapturable || !cmpCapturable.CanCapture(entityOwner))) continue; if (heightDiff > this.GetRange(type).max) continue; let restrictedClasses = this.GetRestrictedClasses(type); if (!restrictedClasses.length) return true; if (!MatchesClassList(targetClasses, restrictedClasses)) return true; } return false; }; /** * Returns null if we have no preference or the lowest index of a preferred class. */ Attack.prototype.GetPreference = function(target) { let cmpIdentity = Engine.QueryInterface(target, IID_Identity); if (!cmpIdentity) return undefined; let targetClasses = cmpIdentity.GetClassesList(); let minPref = null; for (let type of this.GetAttackTypes()) { let preferredClasses = this.GetPreferredClasses(type); for (let targetClass of targetClasses) { let pref = preferredClasses.indexOf(targetClass); if (pref === 0) return pref; if (pref != -1 && (minPref === null || minPref > pref)) minPref = pref; } } return minPref; }; /** * Get the full range of attack using all available attack types. */ Attack.prototype.GetFullAttackRange = function() { let ret = { "min": Infinity, "max": 0 }; for (let type of this.GetAttackTypes()) { let range = this.GetRange(type); ret.min = Math.min(ret.min, range.min); ret.max = Math.max(ret.max, range.max); } return ret; }; Attack.prototype.GetAttackEffectsData = function(type, splash) { let tp = this.template[type]; if (splash) tp = tp.Splash; return Attacking.GetAttackEffectsData("Attack/" + type + splash ? "/Splash" : "", tp, this.entity); }; +/** + * Find the best attack against a target. + * @param {number} target - The entity-ID of the target. + * @param {boolean} allowCapture - Whether capturing is allowed. + * @return {string} - The preferred attack type. + */ Attack.prototype.GetBestAttackAgainst = function(target, allowCapture) { let cmpFormation = Engine.QueryInterface(target, IID_Formation); if (cmpFormation) { // TODO: Formation against formation needs review let types = this.GetAttackTypes(); return g_AttackTypes.find(attack => types.indexOf(attack) != -1); } let cmpIdentity = Engine.QueryInterface(target, IID_Identity); if (!cmpIdentity) return undefined; - let targetClasses = cmpIdentity.GetClassesList(); - let isTargetClass = className => targetClasses.indexOf(className) != -1; - // Always slaughter domestic animals instead of using a normal attack - if (isTargetClass("Domestic") && this.template.Slaughter) + if (this.template.Slaughter && cmpIdentity.HasClass("Domestic")) return "Slaughter"; let types = this.GetAttackTypes().filter(type => this.CanAttack(target, [type])); // Check whether the target is capturable and prefer that when it is allowed. let captureIndex = types.indexOf("Capture"); if (captureIndex != -1) { if (allowCapture) return "Capture"; types.splice(captureIndex, 1); } - let isPreferred = className => this.GetPreferredClasses(className).some(isTargetClass); + let targetClasses = cmpIdentity.GetClassesList(); + let isPreferred = attackType => MatchesClassList(targetClasses, this.GetPreferredClasses(attackType)); return types.sort((a, b) => (types.indexOf(a) + (isPreferred(a) ? types.length : 0)) - (types.indexOf(b) + (isPreferred(b) ? types.length : 0))).pop(); }; Attack.prototype.CompareEntitiesByPreference = function(a, b) { let aPreference = this.GetPreference(a); let bPreference = this.GetPreference(b); if (aPreference === null && bPreference === null) return 0; if (aPreference === null) return 1; if (bPreference === null) return -1; return aPreference - bPreference; }; Attack.prototype.GetTimers = function(type) { let prepare = +(this.template[type].PrepareTime || 0); prepare = ApplyValueModificationsToEntity("Attack/" + type + "/PrepareTime", prepare, this.entity); let repeat = +(this.template[type].RepeatTime || 1000); repeat = ApplyValueModificationsToEntity("Attack/" + type + "/RepeatTime", repeat, this.entity); return { "prepare": prepare, "repeat": repeat }; }; Attack.prototype.GetSplashDamage = function(type) { if (!this.template[type].Splash) return false; return { "attackData": this.GetAttackEffectsData(type, true), "friendlyFire": this.template[type].Splash.FriendlyFire != "false", "shape": this.template[type].Splash.Shape, }; }; Attack.prototype.GetRange = function(type) { let max = +this.template[type].MaxRange; max = ApplyValueModificationsToEntity("Attack/" + type + "/MaxRange", max, this.entity); let min = +(this.template[type].MinRange || 0); min = ApplyValueModificationsToEntity("Attack/" + type + "/MinRange", min, this.entity); let elevationBonus = +(this.template[type].ElevationBonus || 0); elevationBonus = ApplyValueModificationsToEntity("Attack/" + type + "/ElevationBonus", elevationBonus, this.entity); return { "max": max, "min": min, "elevationBonus": elevationBonus }; }; /** * Attack the target entity. This should only be called after a successful range check, * and should only be called after GetTimers().repeat msec has passed since the last * call to PerformAttack. */ Attack.prototype.PerformAttack = function(type, target) { let attackerOwner = Engine.QueryInterface(this.entity, IID_Ownership).GetOwner(); // If this is a ranged attack, then launch a projectile if (type == "Ranged") { let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); let turnLength = cmpTimer.GetLatestTurnLength()/1000; // In the future this could be extended: // * Obstacles like trees could reduce the probability of the target being hit // * Obstacles like walls should block projectiles entirely let horizSpeed = +this.template[type].Projectile.Speed; let gravity = +this.template[type].Projectile.Gravity; // horizSpeed /= 2; gravity /= 2; // slow it down for testing let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return; let selfPosition = cmpPosition.GetPosition(); let cmpTargetPosition = Engine.QueryInterface(target, IID_Position); if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) return; let targetPosition = cmpTargetPosition.GetPosition(); let previousTargetPosition = Engine.QueryInterface(target, IID_Position).GetPreviousPosition(); let targetVelocity = Vector3D.sub(targetPosition, previousTargetPosition).div(turnLength); let timeToTarget = this.PredictTimeToTarget(selfPosition, horizSpeed, targetPosition, targetVelocity); let predictedPosition = (timeToTarget !== false) ? Vector3D.mult(targetVelocity, timeToTarget).add(targetPosition) : targetPosition; // Add inaccuracy based on spread. let distanceModifiedSpread = ApplyValueModificationsToEntity("Attack/Ranged/Spread", +this.template[type].Projectile.Spread, this.entity) * predictedPosition.horizDistanceTo(selfPosition) / 100; let randNorm = randomNormal2D(); let offsetX = randNorm[0] * distanceModifiedSpread; let offsetZ = randNorm[1] * distanceModifiedSpread; let realTargetPosition = new Vector3D(predictedPosition.x + offsetX, targetPosition.y, predictedPosition.z + offsetZ); // Recalculate when the missile will hit the target position. let realHorizDistance = realTargetPosition.horizDistanceTo(selfPosition); timeToTarget = realHorizDistance / horizSpeed; let missileDirection = Vector3D.sub(realTargetPosition, selfPosition).div(realHorizDistance); // Launch the graphical projectile. let cmpProjectileManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ProjectileManager); let actorName = ""; let impactActorName = ""; let impactAnimationLifetime = 0; actorName = this.template[type].Projectile.ActorName || ""; impactActorName = this.template[type].Projectile.ImpactActorName || ""; impactAnimationLifetime = this.template[type].Projectile.ImpactAnimationLifetime || 0; // TODO: Use unit rotation to implement x/z offsets. let deltaLaunchPoint = new Vector3D(0, this.template[type].Projectile.LaunchPoint["@y"], 0.0); let launchPoint = Vector3D.add(selfPosition, deltaLaunchPoint); let cmpVisual = Engine.QueryInterface(this.entity, IID_Visual); if (cmpVisual) { // if the projectile definition is missing from the template // then fallback to the projectile name and launchpoint in the visual actor if (!actorName) actorName = cmpVisual.GetProjectileActor(); let visualActorLaunchPoint = cmpVisual.GetProjectileLaunchPoint(); if (visualActorLaunchPoint.length() > 0) launchPoint = visualActorLaunchPoint; } let id = cmpProjectileManager.LaunchProjectileAtPoint(launchPoint, realTargetPosition, horizSpeed, gravity, actorName, impactActorName, impactAnimationLifetime); let attackImpactSound = ""; let cmpSound = Engine.QueryInterface(this.entity, IID_Sound); if (cmpSound) attackImpactSound = cmpSound.GetSoundGroup("attack_impact_" + type.toLowerCase()); let data = { "type": type, "attackData": this.GetAttackEffectsData(type), "target": target, "attacker": this.entity, "attackerOwner": attackerOwner, "position": realTargetPosition, "direction": missileDirection, "projectileId": id, "attackImpactSound": attackImpactSound }; if (this.template[type].Splash) data.splash = { "friendlyFire": this.template[type].Splash.FriendlyFire != "false", "radius": +this.template[type].Splash.Range, "shape": this.template[type].Splash.Shape, "attackData": this.GetAttackEffectsData(type, true), }; cmpTimer.SetTimeout(SYSTEM_ENTITY, IID_DelayedDamage, "MissileHit", +this.template[type].Delay + timeToTarget * 1000, data); } else Attacking.HandleAttackEffects(type, this.GetAttackEffectsData(type), target, this.entity, attackerOwner); }; /** * Get the predicted time of collision between a projectile (or a chaser) * and its target, assuming they both move in straight line at a constant speed. * Vertical component of movement is ignored. * @param {Vector3D} selfPosition - the 3D position of the projectile (or chaser). * @param {number} horizSpeed - the horizontal speed of the projectile (or chaser). * @param {Vector3D} targetPosition - the 3D position of the target. * @param {Vector3D} targetVelocity - the 3D velocity vector of the target. * @return {Vector3D|boolean} - the 3D predicted position or false if the collision will not happen. */ Attack.prototype.PredictTimeToTarget = function(selfPosition, horizSpeed, targetPosition, targetVelocity) { let relativePosition = new Vector3D.sub(targetPosition, selfPosition); let a = targetVelocity.x * targetVelocity.x + targetVelocity.z * targetVelocity.z - horizSpeed * horizSpeed; let b = relativePosition.x * targetVelocity.x + relativePosition.z * targetVelocity.z; let c = relativePosition.x * relativePosition.x + relativePosition.z * relativePosition.z; // The predicted time to reach the target is the smallest non negative solution // (when it exists) of the equation a t^2 + 2 b t + c = 0. // Using c>=0, we can straightly compute the right solution. if (c == 0) return 0; let disc = b * b - a * c; if (a < 0 || b < 0 && disc >= 0) return c / (Math.sqrt(disc) - b); return false; }; Attack.prototype.OnValueModification = function(msg) { if (msg.component != "Attack") return; let cmpUnitAI = Engine.QueryInterface(this.entity, IID_UnitAI); if (!cmpUnitAI) return; if (this.GetAttackTypes().some(type => msg.valueNames.indexOf("Attack/" + type + "/MaxRange") != -1)) cmpUnitAI.UpdateRangeQueries(); }; Attack.prototype.GetRangeOverlays = function() { if (!this.template.Ranged || !this.template.Ranged.RangeOverlay) return []; let range = this.GetRange("Ranged"); let rangeOverlays = []; for (let i in range) if ((i == "min" || i == "max") && range[i]) rangeOverlays.push({ "radius": range[i], "texture": this.template.Ranged.RangeOverlay.LineTexture, "textureMask": this.template.Ranged.RangeOverlay.LineTextureMask, "thickness": +this.template.Ranged.RangeOverlay.LineThickness, }); return rangeOverlays; }; Engine.RegisterComponentType(IID_Attack, "Attack", Attack); Index: ps/trunk/binaries/data/mods/public/simulation/components/GarrisonHolder.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/GarrisonHolder.js (revision 22768) +++ ps/trunk/binaries/data/mods/public/simulation/components/GarrisonHolder.js (revision 22769) @@ -1,725 +1,729 @@ function GarrisonHolder() {} GarrisonHolder.prototype.Schema = "" + "" + "" + "" + "" + "tokens" + "" + "" + "" + "" + "" + "" + "" + "" + "tokens" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + ""; /** * Initialize GarrisonHolder Component * Garrisoning when loading a map is set in the script of the map, by setting initGarrison * which should contain the array of garrisoned entities. */ GarrisonHolder.prototype.Init = function() { // Garrisoned Units this.entities = []; this.timer = undefined; this.allowGarrisoning = new Map(); this.visibleGarrisonPoints = []; if (this.template.VisibleGarrisonPoints) { let points = this.template.VisibleGarrisonPoints; for (let point in points) this.visibleGarrisonPoints.push({ "offset": { "x": +points[point].X, "y": +points[point].Y, "z": +points[point].Z }, "angle": points[point].Angle ? +points[point].Angle * Math.PI / 180 : null, "entity": null }); } }; /** * @return {Object} max and min range at which entities can garrison the holder. */ GarrisonHolder.prototype.GetLoadingRange = function() { return { "max": +this.template.LoadingRange, "min": 0 }; }; GarrisonHolder.prototype.CanPickup = function(ent) { if (!this.template.Pickup || this.IsFull()) return false; let cmpOwner = Engine.QueryInterface(this.entity, IID_Ownership); return !!cmpOwner && IsOwnedByPlayer(cmpOwner.GetOwner(), ent); }; GarrisonHolder.prototype.GetEntities = function() { return this.entities; }; /** * @return {Array} unit classes which can be garrisoned inside this * particular entity. Obtained from the entity's template. */ GarrisonHolder.prototype.GetAllowedClasses = function() { return this.template.List._string; }; GarrisonHolder.prototype.GetCapacity = function() { return ApplyValueModificationsToEntity("GarrisonHolder/Max", +this.template.Max, this.entity); }; GarrisonHolder.prototype.IsFull = function() { return this.GetGarrisonedEntitiesCount() >= this.GetCapacity(); }; GarrisonHolder.prototype.GetHealRate = function() { return ApplyValueModificationsToEntity("GarrisonHolder/BuffHeal", +this.template.BuffHeal, this.entity); }; /** * Set this entity to allow or disallow garrisoning in the entity. * Every component calling this function should do it with its own ID, and as long as one * component doesn't allow this entity to garrison, it can't be garrisoned * When this entity already contains garrisoned soldiers, * these will not be able to ungarrison until the flag is set to true again. * * This more useful for modern-day features. For example you can't garrison or ungarrison * a driving vehicle or plane. * @param {boolean} allow - Whether the entity should be garrisonable. */ GarrisonHolder.prototype.AllowGarrisoning = function(allow, callerID) { this.allowGarrisoning.set(callerID, allow); }; GarrisonHolder.prototype.IsGarrisoningAllowed = function() { return Array.from(this.allowGarrisoning.values()).every(allow => allow); }; GarrisonHolder.prototype.GetGarrisonedEntitiesCount = function() { let count = this.entities.length; for (let ent of this.entities) { let cmpGarrisonHolder = Engine.QueryInterface(ent, IID_GarrisonHolder); if (cmpGarrisonHolder) count += cmpGarrisonHolder.GetGarrisonedEntitiesCount(); } return count; }; GarrisonHolder.prototype.IsAllowedToGarrison = function(ent) { if (!this.IsGarrisoningAllowed()) return false; if (!IsOwnedByMutualAllyOfEntity(ent, this.entity)) return false; let cmpIdentity = Engine.QueryInterface(ent, IID_Identity); if (!cmpIdentity) return false; let entityClasses = cmpIdentity.GetClassesList(); return MatchesClassList(entityClasses, this.template.List._string) && !!Engine.QueryInterface(ent, IID_Garrisonable); }; /** * Garrison a unit inside. The timer for AutoHeal is started here. * @param {number} vgpEntity - The visual garrison point that will be used. * If vgpEntity is given, this visualGarrisonPoint will be used for the entity. * @return {boolean} Whether the entity was garrisonned. */ GarrisonHolder.prototype.Garrison = function(entity, vgpEntity) { let cmpPosition = Engine.QueryInterface(entity, IID_Position); if (!cmpPosition) return false; if (!this.PerformGarrison(entity)) return false; let visibleGarrisonPoint = vgpEntity; if (!visibleGarrisonPoint) for (let vgp of this.visibleGarrisonPoints) { if (vgp.entity) continue; visibleGarrisonPoint = vgp; break; } if (visibleGarrisonPoint) { visibleGarrisonPoint.entity = entity; // Angle of turrets: // Renamed entities (vgpEntity != undefined) should keep their angle. // Otherwise if an angle is given in the visibleGarrisonPoint, use it. // If no such angle given (usually walls for which outside/inside not well defined), we keep // the current angle as it was used for garrisoning and thus quite often was from inside to // outside, except when garrisoning from outWorld where we take as default PI. let cmpTurretPosition = Engine.QueryInterface(this.entity, IID_Position); if (!vgpEntity && visibleGarrisonPoint.angle != null) cmpPosition.SetYRotation(cmpTurretPosition.GetRotation().y + visibleGarrisonPoint.angle); else if (!vgpEntity && !cmpPosition.IsInWorld()) cmpPosition.SetYRotation(cmpTurretPosition.GetRotation().y + Math.PI); let cmpUnitMotion = Engine.QueryInterface(entity, IID_UnitMotion); if (cmpUnitMotion) cmpUnitMotion.SetFacePointAfterMove(false); cmpPosition.SetTurretParent(this.entity, visibleGarrisonPoint.offset); let cmpUnitAI = Engine.QueryInterface(entity, IID_UnitAI); if (cmpUnitAI) cmpUnitAI.SetTurretStance(); } else cmpPosition.MoveOutOfWorld(); return true; }; /** * @return {boolean} Whether the entity was garrisonned. */ GarrisonHolder.prototype.PerformGarrison = function(entity) { if (!this.HasEnoughHealth()) return false; // Check if the unit is allowed to be garrisoned inside the building if (!this.IsAllowedToGarrison(entity)) return false; // Check the capacity let extraCount = 0; let cmpGarrisonHolder = Engine.QueryInterface(entity, IID_GarrisonHolder); if (cmpGarrisonHolder) extraCount += cmpGarrisonHolder.GetGarrisonedEntitiesCount(); if (this.GetGarrisonedEntitiesCount() + extraCount >= this.GetCapacity()) return false; if (!this.timer && this.GetHealRate() > 0) { let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); this.timer = cmpTimer.SetTimeout(this.entity, IID_GarrisonHolder, "HealTimeout", 1000, {}); } // Actual garrisoning happens here this.entities.push(entity); this.UpdateGarrisonFlag(); let cmpProductionQueue = Engine.QueryInterface(entity, IID_ProductionQueue); if (cmpProductionQueue) cmpProductionQueue.PauseProduction(); let cmpAura = Engine.QueryInterface(entity, IID_Auras); if (cmpAura && cmpAura.HasGarrisonAura()) cmpAura.ApplyGarrisonAura(this.entity); Engine.PostMessage(this.entity, MT_GarrisonedUnitsChanged, { "added": [entity], "removed": [] }); return true; }; /** * Simply eject the unit from the garrisoning entity without moving it * @param {number} entity - Id of the entity to be ejected. * @param {boolean} forced - Whether eject is forced (i.e. if building is destroyed). * @return {boolean} Whether the entity was ejected. */ GarrisonHolder.prototype.Eject = function(entity, forced) { let entityIndex = this.entities.indexOf(entity); // Error: invalid entity ID, usually it's already been ejected if (entityIndex == -1) return false; // Find spawning location let cmpFootprint = Engine.QueryInterface(this.entity, IID_Footprint); let cmpHealth = Engine.QueryInterface(this.entity, IID_Health); let cmpIdentity = Engine.QueryInterface(this.entity, IID_Identity); // If the garrisonHolder is a sinking ship, restrict the location to the intersection of both passabilities // TODO: should use passability classes to be more generic let pos; if ((!cmpHealth || cmpHealth.GetHitpoints() == 0) && cmpIdentity && cmpIdentity.HasClass("Ship")) pos = cmpFootprint.PickSpawnPointBothPass(entity); else pos = cmpFootprint.PickSpawnPoint(entity); if (pos.y < 0) { // Error: couldn't find space satisfying the unit's passability criteria if (!forced) return false; // If ejection is forced, we need to continue, so use center of the building let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); pos = cmpPosition.GetPosition(); } this.entities.splice(entityIndex, 1); let cmpEntPosition = Engine.QueryInterface(entity, IID_Position); let cmpEntUnitAI = Engine.QueryInterface(entity, IID_UnitAI); for (let vgp of this.visibleGarrisonPoints) { if (vgp.entity != entity) continue; cmpEntPosition.SetTurretParent(INVALID_ENTITY, new Vector3D()); let cmpEntUnitMotion = Engine.QueryInterface(entity, IID_UnitMotion); if (cmpEntUnitMotion) cmpEntUnitMotion.SetFacePointAfterMove(true); if (cmpEntUnitAI) cmpEntUnitAI.ResetTurretStance(); vgp.entity = null; break; } if (cmpEntUnitAI) cmpEntUnitAI.Ungarrison(); let cmpEntProductionQueue = Engine.QueryInterface(entity, IID_ProductionQueue); if (cmpEntProductionQueue) cmpEntProductionQueue.UnpauseProduction(); let cmpEntAura = Engine.QueryInterface(entity, IID_Auras); if (cmpEntAura && cmpEntAura.HasGarrisonAura()) cmpEntAura.RemoveGarrisonAura(this.entity); cmpEntPosition.JumpTo(pos.x, pos.z); cmpEntPosition.SetHeightOffset(0); let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (cmpPosition) cmpEntPosition.SetYRotation(cmpPosition.GetPosition().horizAngleTo(pos)); Engine.PostMessage(this.entity, MT_GarrisonedUnitsChanged, { "added": [], "removed": [entity] }); return true; }; /** * Ejects units and orders them to move to the rally point. If an ejection * with a given obstruction radius has failed, we won't try anymore to eject * entities with a bigger obstruction as that is compelled to also fail. * @param {Array} entities - An array containing the ids of the entities to eject. * @param {boolean} forced - Whether eject is forced (ie: if building is destroyed). * @return {boolean} Whether the entities were ejected. */ GarrisonHolder.prototype.PerformEject = function(entities, forced) { if (!this.IsGarrisoningAllowed() && !forced) return false; let ejectedEntities = []; let success = true; let failedRadius; let radius; let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); for (let entity of entities) { if (failedRadius !== undefined) { let cmpObstruction = Engine.QueryInterface(entity, IID_Obstruction); radius = cmpObstruction ? cmpObstruction.GetUnitRadius() : 0; if (radius >= failedRadius) continue; } if (this.Eject(entity, forced)) { let cmpEntOwnership = Engine.QueryInterface(entity, IID_Ownership); if (cmpOwnership && cmpEntOwnership && cmpOwnership.GetOwner() == cmpEntOwnership.GetOwner()) ejectedEntities.push(entity); } else { success = false; if (failedRadius !== undefined) failedRadius = Math.min(failedRadius, radius); else { let cmpObstruction = Engine.QueryInterface(entity, IID_Obstruction); failedRadius = cmpObstruction ? cmpObstruction.GetUnitRadius() : 0; } } } this.OrderWalkToRallyPoint(ejectedEntities); this.UpdateGarrisonFlag(); return success; }; /** * Order entities to walk to the rally point. * @param {Array} entities - An array containing all the ids of the entities. */ GarrisonHolder.prototype.OrderWalkToRallyPoint = function(entities) { let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); let cmpRallyPoint = Engine.QueryInterface(this.entity, IID_RallyPoint); if (!cmpRallyPoint || !cmpRallyPoint.GetPositions()[0]) return; let commands = GetRallyPointCommands(cmpRallyPoint, entities); // Ignore the rally point if it is autogarrison if (commands[0].type == "garrison" && commands[0].target == this.entity) return; for (let command of commands) ProcessCommand(cmpOwnership.GetOwner(), command); }; /** * Unload unit from the garrisoning entity and order them * to move to the rally point. * @return {boolean} Whether the command was successful. */ GarrisonHolder.prototype.Unload = function(entity, forced) { return this.PerformEject([entity], forced); }; /** * Unload one or all units that match a template and owner from * the garrisoning entity and order them to move to the rally point. * @param {string} template - Type of units that should be ejected. * @param {number} owner - Id of the player whose units should be ejected. * @param {boolean} all - Whether all units should be ejected. * @param {boolean} forced - Whether unload is forced. * @return {boolean} Whether the unloading was successful. */ GarrisonHolder.prototype.UnloadTemplate = function(template, owner, all, forced) { let entities = []; let cmpTemplateManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager); for (let entity of this.entities) { let cmpIdentity = Engine.QueryInterface(entity, IID_Identity); // Units with multiple ranks are grouped together. let name = cmpIdentity.GetSelectionGroupName() || cmpTemplateManager.GetCurrentTemplateName(entity); if (name != template || owner != Engine.QueryInterface(entity, IID_Ownership).GetOwner()) continue; entities.push(entity); // If 'all' is false, only ungarrison the first matched unit. if (!all) break; } return this.PerformEject(entities, forced); }; /** * Unload all units, that belong to certain player * and order all own units to move to the rally point. * @param {boolean} forced - Whether unload is forced. * @param {number} owner - Id of the player whose units should be ejected. * @return {boolean} Whether the unloading was successful. */ GarrisonHolder.prototype.UnloadAllByOwner = function(owner, forced) { let entities = this.entities.filter(ent => { let cmpOwnership = Engine.QueryInterface(ent, IID_Ownership); return cmpOwnership && cmpOwnership.GetOwner() == owner; }); return this.PerformEject(entities, forced); }; /** * Unload all units from the entity and order them to move to the rally point. * @param {boolean} forced - Whether unload is forced. * @return {boolean} Whether the unloading was successful. */ GarrisonHolder.prototype.UnloadAll = function(forced) { return this.PerformEject(this.entities.slice(), forced); }; /** * Used to check if the garrisoning entity's health has fallen below * a certain limit after which all garrisoned units are unloaded. */ GarrisonHolder.prototype.OnHealthChanged = function(msg) { if (!this.HasEnoughHealth() && this.entities.length) this.EjectOrKill(this.entities.slice()); }; GarrisonHolder.prototype.HasEnoughHealth = function() { let cmpHealth = Engine.QueryInterface(this.entity, IID_Health); return cmpHealth.GetHitpoints() > Math.floor(+this.template.EjectHealth * cmpHealth.GetMaxHitpoints()); }; /** * Called every second. Heals garrisoned units. */ GarrisonHolder.prototype.HealTimeout = function(data) { let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); if (!this.entities.length) { cmpTimer.CancelTimer(this.timer); this.timer = undefined; return; } for (let entity of this.entities) { let cmpHealth = Engine.QueryInterface(entity, IID_Health); if (cmpHealth && !cmpHealth.IsUnhealable()) cmpHealth.Increase(this.GetHealRate()); } this.timer = cmpTimer.SetTimeout(this.entity, IID_GarrisonHolder, "HealTimeout", 1000, {}); }; /** * Updates the garrison flag depending whether something is garrisoned in the entity. */ GarrisonHolder.prototype.UpdateGarrisonFlag = function() { let cmpVisual = Engine.QueryInterface(this.entity, IID_Visual); if (!cmpVisual) return; cmpVisual.SetVariant("garrison", this.entities.length ? "garrisoned" : "ungarrisoned"); }; /** * Cancel timer when destroyed. */ GarrisonHolder.prototype.OnDestroy = function() { if (this.timer) { let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); cmpTimer.CancelTimer(this.timer); } }; /** * If a garrisoned entity is captured, or about to be killed (so its owner changes to '-1'), * remove it from the building so we only ever contain valid entities. */ GarrisonHolder.prototype.OnGlobalOwnershipChanged = function(msg) { // The ownership change may be on the garrisonholder if (this.entity == msg.entity) { let entities = this.entities.filter(ent => msg.to == INVALID_PLAYER || !IsOwnedByMutualAllyOfEntity(this.entity, ent)); if (entities.length) this.EjectOrKill(entities); return; } // or on some of its garrisoned units let entityIndex = this.entities.indexOf(msg.entity); if (entityIndex != -1) { // If the entity is dead, remove it directly instead of ejecting the corpse let cmpHealth = Engine.QueryInterface(msg.entity, IID_Health); if (cmpHealth && cmpHealth.GetHitpoints() == 0) { this.entities.splice(entityIndex, 1); Engine.PostMessage(this.entity, MT_GarrisonedUnitsChanged, { "added": [], "removed": [msg.entity] }); this.UpdateGarrisonFlag(); for (let point of this.visibleGarrisonPoints) if (point.entity == msg.entity) point.entity = null; } else if (msg.to == INVALID_PLAYER || !IsOwnedByMutualAllyOfEntity(this.entity, msg.entity)) this.EjectOrKill([msg.entity]); } }; /** * Update list of garrisoned entities if one gets renamed (e.g. by promotion). */ GarrisonHolder.prototype.OnGlobalEntityRenamed = function(msg) { let entityIndex = this.entities.indexOf(msg.entity); if (entityIndex != -1) { let vgpRenamed; for (let vgp of this.visibleGarrisonPoints) { if (vgp.entity != msg.entity) continue; vgpRenamed = vgp; break; } this.Eject(msg.entity, true); this.Garrison(msg.newentity, vgpRenamed); } if (!this.initGarrison) return; // Update the pre-game garrison because of SkirmishReplacement if (msg.entity == this.entity) { let cmpGarrisonHolder = Engine.QueryInterface(msg.newentity, IID_GarrisonHolder); if (cmpGarrisonHolder) cmpGarrisonHolder.initGarrison = this.initGarrison; } else { entityIndex = this.initGarrison.indexOf(msg.entity); if (entityIndex != -1) this.initGarrison[entityIndex] = msg.newentity; } }; /** * Eject all foreign garrisoned entities which are no more allied. */ GarrisonHolder.prototype.OnDiplomacyChanged = function() { this.EjectOrKill(this.entities.filter(ent => !IsOwnedByMutualAllyOfEntity(this.entity, ent))); }; /** * Eject or kill a garrisoned unit which can no more be garrisoned * (garrisonholder's health too small or ownership changed). */ GarrisonHolder.prototype.EjectOrKill = function(entities) { let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); // Eject the units which can be ejected (if not in world, it generally means this holder // is inside a holder which kills its entities, so do not eject) if (cmpPosition && cmpPosition.IsInWorld()) { let ejectables = entities.filter(ent => this.IsEjectable(ent)); if (ejectables.length) this.PerformEject(ejectables, false); } // And destroy all remaining entities let killedEntities = []; for (let entity of entities) { let entityIndex = this.entities.indexOf(entity); if (entityIndex == -1) continue; let cmpHealth = Engine.QueryInterface(entity, IID_Health); if (cmpHealth) cmpHealth.Kill(); this.entities.splice(entityIndex, 1); killedEntities.push(entity); } if (killedEntities.length) Engine.PostMessage(this.entity, MT_GarrisonedUnitsChanged, { "added": [], "removed": killedEntities }); this.UpdateGarrisonFlag(); }; +/** + * Whether an entity is ejectable. + * @param {number} entity - The entity-ID to be tested. + * @return {boolean} - Whether the entity is ejectable. + */ GarrisonHolder.prototype.IsEjectable = function(entity) { if (!this.entities.find(ent => ent == entity)) return false; let ejectableClasses = this.template.EjectClassesOnDestroy._string; - ejectableClasses = ejectableClasses ? ejectableClasses.split(/\s+/) : []; let entityClasses = Engine.QueryInterface(entity, IID_Identity).GetClassesList(); - return ejectableClasses.some(ejectableClass => entityClasses.indexOf(ejectableClass) != -1); + return MatchesClassList(entityClasses, ejectableClasses); }; /** * Initialise the garrisoned units. */ GarrisonHolder.prototype.OnGlobalInitGame = function(msg) { if (!this.initGarrison) return; for (let ent of this.initGarrison) { let cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); if (cmpUnitAI && cmpUnitAI.CanGarrison(this.entity) && this.Garrison(ent)) cmpUnitAI.Autogarrison(this.entity); } this.initGarrison = undefined; }; GarrisonHolder.prototype.OnValueModification = function(msg) { if (msg.component != "GarrisonHolder" || msg.valueNames.indexOf("GarrisonHolder/BuffHeal") == -1) return; if (this.timer && this.GetHealRate() == 0) { let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); cmpTimer.CancelTimer(this.timer); this.timer = undefined; } else if (!this.timer && this.GetHealRate() > 0) { let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); this.timer = cmpTimer.SetTimeout(this.entity, IID_GarrisonHolder, "HealTimeout", 1000, {}); } }; Engine.RegisterComponentType(IID_GarrisonHolder, "GarrisonHolder", GarrisonHolder); 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 22768) +++ ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Damage.js (revision 22769) @@ -1,547 +1,547 @@ Engine.LoadHelperScript("DamageBonus.js"); Engine.LoadHelperScript("Attacking.js"); Engine.LoadHelperScript("Player.js"); Engine.LoadHelperScript("Sound.js"); Engine.LoadHelperScript("ValueModification.js"); Engine.LoadComponentScript("interfaces/Attack.js"); Engine.LoadComponentScript("interfaces/AttackDetection.js"); Engine.LoadComponentScript("interfaces/DelayedDamage.js"); Engine.LoadComponentScript("interfaces/Resistance.js"); Engine.LoadComponentScript("interfaces/Health.js"); Engine.LoadComponentScript("interfaces/Loot.js"); Engine.LoadComponentScript("interfaces/Player.js"); Engine.LoadComponentScript("interfaces/Promotion.js"); Engine.LoadComponentScript("interfaces/StatusEffectsReceiver.js"); Engine.LoadComponentScript("interfaces/ModifiersManager.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, "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": (effectData, __, ___, bonusMultiplier) => { damageTaken = true; return { "killed": false, "HPchange": -bonusMultiplier * effectData.Crush }; }, }); 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": [] }, 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(data.type, data.attackData, data.target, data.attacker, data.attackerOwner); TestDamage(); data.type = "Ranged"; type = data.type; Attacking.HandleAttackEffects(data.type, data.attackData, data.target, 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), "playersToDamage": [2], }; 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(SYSTEM_ENTITY, IID_RangeManager, { "ExecuteQueryAroundPos": () => [60, 61, 62], }); AddMock(60, IID_Position, { "GetPosition2D": () => new Vector2D(2.2, -0.4), }); AddMock(61, IID_Position, { "GetPosition2D": () => new Vector2D(0, 0), }); AddMock(62, IID_Position, { "GetPosition2D": () => new Vector2D(5, 2), }); AddMock(60, IID_Health, { "TakeDamage": (effectData, __, ___, mult) => { hitEnts.add(60); TS_ASSERT_EQUALS(mult * (effectData.Hack + effectData.Pierce + effectData.Crush), 100 * fallOff(2.2, -0.4)); return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); AddMock(61, IID_Health, { "TakeDamage": (effectData, __, ___, mult) => { hitEnts.add(61); TS_ASSERT_EQUALS(mult * (effectData.Hack + effectData.Pierce + effectData.Crush), 100 * fallOff(0, 0)); return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); AddMock(62, IID_Health, { "TakeDamage": (effectData, __, ___, mult) => { hitEnts.add(62); TS_ASSERT_EQUALS(mult * (effectData.Hack + effectData.Pierce + effectData.Crush), 0); return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); 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": (effectData, __, ___, mult) => { hitEnts.add(60); TS_ASSERT_EQUALS(mult * (effectData.Hack + effectData.Pierce + effectData.Crush), 100 * fallOff(1, 2)); return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); 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 fallOff = function(r) { return 1 - r * r / (radius * radius); }; AddMock(SYSTEM_ENTITY, IID_RangeManager, { "ExecuteQueryAroundPos": () => [60, 61, 62, 64], }); 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 AddMock(64, IID_Position, { "GetPosition2D": () => new Vector2D(9, -4), }); AddMock(60, IID_Resistance, { "TakeDamage": (effectData, __, ___, mult) => { TS_ASSERT_EQUALS(mult * (effectData.Hack + effectData.Pierce + effectData.Crush), 100 * fallOff(0)); return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); AddMock(61, IID_Resistance, { "TakeDamage": (effectData, __, ___, mult) => { TS_ASSERT_EQUALS(mult * (effectData.Hack + effectData.Pierce + effectData.Crush), 100 * fallOff(5)); return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); AddMock(62, IID_Resistance, { "TakeDamage": (effectData, __, ___, mult) => { TS_ASSERT_EQUALS(mult * (effectData.Hack + effectData.Pierce + effectData.Crush), 100 * fallOff(1)); return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); AddMock(63, IID_Resistance, { "TakeDamage": (effectData, __, ___, mult) => { TS_ASSERT(false); } }); AddMock(64, IID_Resistance, { "TakeDamage": (effectData, __, ___, mult) => { TS_ASSERT_EQUALS(mult * (effectData.Hack + effectData.Pierce + effectData.Crush), 0); return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); Attacking.CauseDamageOverArea({ "type": "Ranged", "attackData": { "Damage": { "Hack": 100, "Pierce": 0, "Crush": 0 } }, "attacker": 50, "attackerOwner": 1, "origin": new Vector2D(3, 4), "radius": radius, "shape": "Circular", "playersToDamage": [2], }); } 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, }; 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": (effectData, __, ___, mult) => { hitEnts.add(60); TS_ASSERT_EQUALS(mult * (effectData.Hack + effectData.Pierce + effectData.Crush), 100); return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); 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(); // 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": (effectData, __, ___, mult) => { TS_ASSERT_EQUALS(false); return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); AddMock(SYSTEM_ENTITY, IID_RangeManager, { "ExecuteQueryAroundPos": () => [61] }); AddMock(61, IID_Position, { "GetPosition": () => targetPos, "GetPreviousPosition": () => targetPos, "GetPosition2D": () => Vector2D.from3D(targetPos), "IsInWorld": () => true, }); AddMock(61, IID_Health, { "TakeDamage": (effectData, __, ___, mult) => { hitEnts.add(61); TS_ASSERT_EQUALS(mult * (effectData.Hack + effectData.Pierce + effectData.Crush), 100); return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); AddMock(61, IID_Footprint, { "GetShape": () => ({ "type": "circle", "radius": 20 }), }); 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] }); let dealtDamage = 0; AddMock(61, IID_Health, { "TakeDamage": (effectData, __, ___, mult) => { hitEnts.add(61); dealtDamage += mult * (effectData.Hack + effectData.Pierce + effectData.Crush); return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); 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": (effectData, __, ___, mult) => { hitEnts.add(62); TS_ASSERT_EQUALS(mult * (effectData.Hack + effectData.Pierce + effectData.Crush), 200 * 0.75); return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); 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] }); let bonus= { "BonusCav": { "Classes": "Cavalry", "Multiplier": 400 } }; let splashBonus = { "BonusCav": { "Classes": "Cavalry", "Multiplier": 10000 } }; AddMock(61, IID_Identity, { - "HasClass": cl => cl == "Cavalry" + "GetClassesList": () => ["Cavalry"] }); 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_MissileHit(); Index: ps/trunk/binaries/data/mods/public/simulation/helpers/DamageBonus.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/helpers/DamageBonus.js (revision 22768) +++ ps/trunk/binaries/data/mods/public/simulation/helpers/DamageBonus.js (revision 22769) @@ -1,31 +1,30 @@ /** * Calculates the attack damage multiplier against a target. * @param {entity_id_t} source - The source entity's id. * @param {entity_id_t} 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. */ function GetAttackBonus(source, target, type, template) { let attackBonus = 1; let cmpIdentity = Engine.QueryInterface(target, IID_Identity); if (!cmpIdentity) return 1; // Multiply the bonuses for all matching classes for (let key in template) { let bonus = template[key]; if (bonus.Civ && bonus.Civ !== cmpIdentity.GetCiv()) continue; - if (bonus.Classes && bonus.Classes.split(/\s+/).some(cls => !cmpIdentity.HasClass(cls))) - continue; - attackBonus *= ApplyValueModificationsToEntity("Attack/" + type + "/Bonuses/" + key + "/Multiplier", +bonus.Multiplier, source); + if (!bonus.Classes || MatchesClassList(cmpIdentity.GetClassesList(), bonus.Classes)) + attackBonus *= ApplyValueModificationsToEntity("Attack/" + type + "/Bonuses/" + key + "/Multiplier", +bonus.Multiplier, source); } return attackBonus; } Engine.RegisterGlobal("GetAttackBonus", GetAttackBonus);