Index: ps/trunk/binaries/data/mods/public/simulation/components/Attack.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/Attack.js (revision 25012) +++ ps/trunk/binaries/data/mods/public/simulation/components/Attack.js (revision 25013) @@ -1,668 +1,669 @@ 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." + "" + "" + "Spear" + "" + "10.0" + "0.0" + "5.0" + "" + "4.0" + "1000" + "" + "" + "pers" + "Infantry" + "1.5" + "" + "" + "Cavalry Melee" + "1.5" + "" + "" + "Champion" + "Cavalry Infantry" + "" + "" + "Bow" + "" + "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" + "false" + "" + "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 pref = 0; pref < preferredClasses.length; ++pref) { if (MatchesClassList(targetClasses, preferredClasses[pref])) { if (pref === 0) return pref; if ((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 template = this.template[type]; if (splash) template = template.Splash; return Attacking.GetAttackEffectsData("Attack/" + type + (splash ? "/Splash" : ""), template, 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; // Always slaughter domestic animals instead of using a normal attack if (this.template.Slaughter && cmpIdentity.HasClass("Domestic")) return "Slaughter"; let types = this.GetAttackTypes().filter(type => this.CanAttack(target, [type])); // Check whether the target is capturable and prefer that when it is allowed. let captureIndex = types.indexOf("Capture"); if (captureIndex != -1) { if (allowCapture) return "Capture"; types.splice(captureIndex, 1); } 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.GetAttackName = function(type) { return { "name": this.template[type].AttackName._string || this.template[type].AttackName, "context": this.template[type].AttackName["@context"] }; }; Attack.prototype.GetRepeatTime = function(type) { let repeatTime = 1000; if (this.template[type] && this.template[type].RepeatTime) repeatTime = +this.template[type].RepeatTime; return ApplyValueModificationsToEntity("Attack/" + type + "/RepeatTime", repeatTime, this.entity); }; Attack.prototype.GetTimers = function(type) { return { "prepare": ApplyValueModificationsToEntity("Attack/" + type + "/PrepareTime", +(this.template[type].PrepareTime || 0), this.entity), "repeat": this.GetRepeatTime(type) }; }; Attack.prototype.GetSplashData = function(type) { if (!this.template[type].Splash) return undefined; return { "attackData": this.GetAttackEffectsData(type, true), "friendlyFire": this.template[type].Splash.FriendlyFire == "true", "radius": ApplyValueModificationsToEntity("Attack/" + type + "/Splash/Range", +this.template[type].Splash.Range, this.entity), "shape": this.template[type].Splash.Shape, }; }; Attack.prototype.GetRange = function(type) { if (!type) return this.GetFullAttackRange(); 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(); + let data = { + "type": type, + "attackData": this.GetAttackEffectsData(type), + "target": target, + "attacker": this.entity, + "attackerOwner": attackerOwner, + }; + // 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 // We will try to estimate the position of the target, where we can hit it. // We first estimate the time-till-hit by extrapolating linearly the movement // of the last turn. We compute the time till an arrow will intersect the target. 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 targetVelocity = Vector3D.sub(targetPosition, cmpTargetPosition.GetPreviousPosition()).div(turnLength); let timeToTarget = PositionHelper.PredictTimeToTarget(selfPosition, horizSpeed, targetPosition, targetVelocity); // 'Cheat' and use UnitMotion to predict the position in the near-future. // This avoids 'dancing' issues with units zigzagging over very short distances. // However, this could fail if the player gives several short move orders, so // occasionally fall back to basic interpolation. let predictedPosition = targetPosition; if (timeToTarget !== false) { // Don't predict too far in the future, but avoid threshold effects. // After 1 second, always use the 'dumb' interpolated past-motion prediction. let useUnitMotion = randBool(Math.max(0, 0.75 - timeToTarget / 1.333)); if (useUnitMotion) { let cmpTargetUnitMotion = Engine.QueryInterface(target, IID_UnitMotion); let cmpTargetUnitAI = Engine.QueryInterface(target, IID_UnitAI); if (cmpTargetUnitMotion && (!cmpTargetUnitAI || !cmpTargetUnitAI.IsFormationMember())) { let pos2D = cmpTargetUnitMotion.EstimateFuturePosition(timeToTarget); predictedPosition.x = pos2D.x; predictedPosition.z = pos2D.y; } else predictedPosition = Vector3D.mult(targetVelocity, timeToTarget).add(targetPosition); } else predictedPosition = Vector3D.mult(targetVelocity, timeToTarget).add(targetPosition); } let predictedHeight = cmpTargetPosition.GetHeightAt(predictedPosition.x, predictedPosition.z); // 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, predictedHeight, 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); 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, - "splash": this.GetSplashData(type), - "friendlyFire": this.template[type].Projectile.FriendlyFire == "true", - }; + data.position = realTargetPosition; + data.direction = missileDirection; + data.projectileId = id; + data.attackImpactSound = attackImpactSound; + data.splash = this.GetSplashData(type); + data.friendlyFire = this.template[type].Projectile.FriendlyFire == "true"; cmpTimer.SetTimeout(SYSTEM_ENTITY, IID_DelayedDamage, "MissileHit", +this.template[type].Delay + timeToTarget * 1000, data); } else - Attacking.HandleAttackEffects(target, type, this.GetAttackEffectsData(type), this.entity, attackerOwner); + Attacking.HandleAttackEffects(target, data); }; 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/DelayedDamage.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/DelayedDamage.js (revision 25012) +++ ps/trunk/binaries/data/mods/public/simulation/components/DelayedDamage.js (revision 25013) @@ -1,92 +1,92 @@ function DelayedDamage() {} DelayedDamage.prototype.Schema = ""; DelayedDamage.prototype.Init = function() { }; /** * When missiles miss their target, other units in MISSILE_HIT_RADIUS range are considered. * Large missiles should probably implement splash damage anyways, * so keep this value low for performance. */ DelayedDamage.prototype.MISSILE_HIT_RADIUS = 2; /** * Handles hit logic after the projectile travel time has passed. * @param {Object} data - The data sent by the caller. * @param {string} data.type - The type of damage. * @param {Object} data.attackData - Data of the form { 'effectType': { ...opaque effect data... }, 'Bonuses': {...} }. * @param {number} data.target - The entity id of the target. * @param {number} data.attacker - The entity id of the attacker. * @param {number} data.attackerOwner - The player id of the owner of the attacker. * @param {Vector2D} data.origin - The origin of the projectile hit. * @param {Vector3D} data.position - The expected position of the target. * @param {number} data.projectileId - The id of the projectile. * @param {Vector3D} data.direction - The unit vector defining the direction. * @param {string} data.attackImpactSound - The name of the sound emited on impact. * @param {boolean} data.friendlyFire - A flag indicating whether allied entities can also be damaged. * ***When splash damage*** * @param {boolean} data.splash.friendlyFire - A flag indicating if allied entities are also damaged. * @param {number} data.splash.radius - The radius of the splash damage. * @param {string} data.splash.shape - The shape of the splash range. * @param {Object} data.splash.attackData - same as attackData, for splash. */ DelayedDamage.prototype.MissileHit = function(data, lateness) { if (!data.position) return; let cmpSoundManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_SoundManager); if (cmpSoundManager && data.attackImpactSound) cmpSoundManager.PlaySoundGroupAtPosition(data.attackImpactSound, data.position); // Do this first in case the direct hit kills the target. if (data.splash) Attacking.CauseDamageOverArea({ "type": data.type, "attackData": data.splash.attackData, "attacker": data.attacker, "attackerOwner": data.attackerOwner, "origin": Vector2D.from3D(data.position), "radius": data.splash.radius, "shape": data.splash.shape, "direction": data.direction, "friendlyFire": data.splash.friendlyFire }); let cmpProjectileManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ProjectileManager); let target = data.target; // Since we can't damage mirages, replace a miraged target by the real target. let cmpMirage = Engine.QueryInterface(data.target, IID_Mirage); if (cmpMirage) target = cmpMirage.GetParent(); // Deal direct damage if we hit the main target // and we could handle the attack. if (PositionHelper.TestCollision(target, data.position, lateness) && - Attacking.HandleAttackEffects(target, data.type, data.attackData, data.attacker, data.attackerOwner)) + Attacking.HandleAttackEffects(target, data)) { cmpProjectileManager.RemoveProjectile(data.projectileId); return; } // If we didn't hit the main target look for nearby units. let ents = PositionHelper.EntitiesNearPoint(Vector2D.from3D(data.position), this.MISSILE_HIT_RADIUS, Attacking.GetPlayersToDamage(data.attackerOwner, data.friendlyFire)); for (let ent of ents) { if (!PositionHelper.TestCollision(ent, data.position, lateness) || - !Attacking.HandleAttackEffects(ent, data.type, data.attackData, data.attacker, data.attackerOwner)) + !Attacking.HandleAttackEffects(ent, data)) continue; cmpProjectileManager.RemoveProjectile(data.projectileId); break; } }; Engine.RegisterSystemComponentType(IID_DelayedDamage, "DelayedDamage", DelayedDamage); Index: ps/trunk/binaries/data/mods/public/simulation/components/StatusEffectsReceiver.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/StatusEffectsReceiver.js (revision 25012) +++ ps/trunk/binaries/data/mods/public/simulation/components/StatusEffectsReceiver.js (revision 25013) @@ -1,165 +1,170 @@ function StatusEffectsReceiver() {} StatusEffectsReceiver.prototype.DefaultInterval = 1000; /** * Initialises the status effects. */ StatusEffectsReceiver.prototype.Init = function() { this.activeStatusEffects = {}; }; /** * Which status effects are active on this entity. * * @return {Object} - An object containing the status effects which currently affect the entity. */ StatusEffectsReceiver.prototype.GetActiveStatuses = function() { return this.activeStatusEffects; }; /** * Called by Attacking effects. Adds status effects for each entry in the effectData. * * @param {Object} effectData - An object containing the status effects to give to the entity. * @param {number} attacker - The entity ID of the attacker. * @param {number} attackerOwner - The player ID of the attacker. * @param {number} bonusMultiplier - A value to multiply the damage with (not implemented yet for SE). * * @return {Object} - The codes of the status effects which were processed. */ StatusEffectsReceiver.prototype.ApplyStatus = function(effectData, attacker, attackerOwner) { let attackerData = { "entity": attacker, "owner": attackerOwner }; for (let effect in effectData) this.AddStatus(effect, effectData[effect], attackerData); // TODO: implement loot? return { "inflictedStatuses": Object.keys(effectData) }; }; /** * Adds a status effect to the entity. * * @param {string} statusCode - The code of the status effect. * @param {Object} data - The various effects and timings. * @param {Object} attackerData - The attacker and attackerOwner. */ StatusEffectsReceiver.prototype.AddStatus = function(baseCode, data, attackerData) { let statusCode = baseCode; if (this.activeStatusEffects[statusCode]) { if (data.Stackability == "Ignore") return; if (data.Stackability == "Extend") { this.activeStatusEffects[statusCode].Duration += data.Duration; return; } if (data.Stackability == "Replace") this.RemoveStatus(statusCode); else if (data.Stackability == "Stack") { let i = 0; let temp; do temp = statusCode + "_" + i++; while (!!this.activeStatusEffects[temp]); statusCode = temp; } } this.activeStatusEffects[statusCode] = { "baseCode": baseCode }; let status = this.activeStatusEffects[statusCode]; Object.assign(status, data); if (status.Modifiers) { let modifications = DeriveModificationsFromXMLTemplate(status.Modifiers); let cmpModifiersManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ModifiersManager); cmpModifiersManager.AddModifiers(statusCode, modifications, this.entity); } // With neither an interval nor a duration, there is no point in starting a timer. if (!status.Duration && !status.Interval) return; // We need this to prevent Status Effects from giving XP // to the entity that applied them. status.StatusEffect = true; // We want an interval to update the GUI to show how much time of the status effect // is left even if the status effect itself has no interval. if (!status.Interval) status._interval = this.DefaultInterval; status._timeElapsed = 0; status._firstTime = true; status.source = attackerData; let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); status._timer = cmpTimer.SetInterval(this.entity, IID_StatusEffectsReceiver, "ExecuteEffect", 0, +(status.Interval || status._interval), statusCode); }; /** * Removes a status effect from the entity. * * @param {string} statusCode - The status effect to be removed. */ StatusEffectsReceiver.prototype.RemoveStatus = function(statusCode) { let statusEffect = this.activeStatusEffects[statusCode]; if (!statusEffect) return; if (statusEffect.Modifiers) { let cmpModifiersManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ModifiersManager); cmpModifiersManager.RemoveAllModifiers(statusCode, this.entity); } if (statusEffect._timer) { let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); cmpTimer.CancelTimer(statusEffect._timer); } delete this.activeStatusEffects[statusCode]; }; /** * Called by the timers. Executes a status effect. * * @param {string} statusCode - The status effect to be executed. * @param {number} lateness - The delay between the calling of the function and the actual execution (turn time?). */ StatusEffectsReceiver.prototype.ExecuteEffect = function(statusCode, lateness) { let status = this.activeStatusEffects[statusCode]; if (!status) return; if (status.Damage || status.Capture) - Attacking.HandleAttackEffects(this.entity, statusCode, status, status.source.entity, status.source.owner); + Attacking.HandleAttackEffects(this.entity, { + "type": statusCode, + "attackData": status, + "attacker": status.source.entity, + "attackerOwner": status.source.owner + }); if (!status.Duration) return; if (status._firstTime) { status._firstTime = false; status._timeElapsed += lateness; } else status._timeElapsed += +(status.Interval || status._interval) + lateness; if (status._timeElapsed >= +status.Duration) this.RemoveStatus(statusCode); }; Engine.RegisterComponentType(IID_StatusEffectsReceiver, "StatusEffectsReceiver", StatusEffectsReceiver); 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 25012) +++ ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Damage.js (revision 25013) @@ -1,707 +1,707 @@ 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("interfaces/UnitAI.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), "GetHeightAt": () => 0, "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); + Attacking.HandleAttackEffects(target, data); TestDamage(); data.type = "Ranged"; type = data.type; - Attacking.HandleAttackEffects(target, data.type, data.attackData, data.attacker, data.attackerOwner); + Attacking.HandleAttackEffects(target, data); 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 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_Resistance.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Resistance.js (revision 25012) +++ ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Resistance.js (revision 25013) @@ -1,373 +1,419 @@ 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"); + let data = { + "type": attackType, + "attackData": attackData, + "attacker": this.AttackerID, + "attackerOwner": this.EnemyID + }; - Attacking.HandleAttackEffects(this.EntityID, attackType, attackData, this.AttackerID, this.EnemyID); + Attacking.HandleAttackEffects(this.EntityID, data); 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); + Attacking.HandleAttackEffects(this.EntityID, data); 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); + Attacking.HandleAttackEffects(this.EntityID, { + "type": "Test", + "attackData": attackData, + "attacker": this.AttackerID, + "attackerOwner": 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); + Attacking.HandleAttackEffects(this.EntityID, { + "type": "Test", + "attackData": attackData, + "attacker": this.AttackerID, + "attackerOwner": 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); + Attacking.HandleAttackEffects(this.EntityID, { + "type": "Test", + "attackData": attackData, + "attacker": this.AttackerID, + "attackerOwner": 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); + Attacking.HandleAttackEffects(this.EntityID, { + "type": "Test", + "attackData": attackData, + "attacker": this.AttackerID, + "attackerOwner": 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); + Attacking.HandleAttackEffects(this.EntityID, { + "type": "Test", + "attackData": attackData, + "attacker": this.AttackerID, + "attackerOwner": 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); + Attacking.HandleAttackEffects(this.EntityID, { + "type": "Test", + "attackData": attackData, + "attacker": this.AttackerID, + "attackerOwner": 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); + Attacking.HandleAttackEffects(this.EntityID, { + "type": "Test", + "attackData": attackData, + "attacker": this.AttackerID, + "attackerOwner": 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); + Attacking.HandleAttackEffects(this.EntityID, { + "type": "Test", + "attackData": attackData, + "attacker": this.AttackerID, + "attackerOwner": 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/components/tests/test_StatusEffectsReceiver.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/tests/test_StatusEffectsReceiver.js (revision 25012) +++ ps/trunk/binaries/data/mods/public/simulation/components/tests/test_StatusEffectsReceiver.js (revision 25013) @@ -1,345 +1,345 @@ Engine.LoadHelperScript("MultiKeyMap.js"); Engine.LoadHelperScript("Player.js"); Engine.LoadHelperScript("ValueModification.js"); Engine.LoadComponentScript("interfaces/Health.js"); Engine.LoadComponentScript("interfaces/ModifiersManager.js"); Engine.LoadComponentScript("interfaces/StatusEffectsReceiver.js"); Engine.LoadComponentScript("interfaces/Timer.js"); Engine.LoadComponentScript("Health.js"); Engine.LoadComponentScript("ModifiersManager.js"); Engine.LoadComponentScript("StatusEffectsReceiver.js"); Engine.LoadComponentScript("Timer.js"); let target = 42; let cmpStatusReceiver = ConstructComponent(target, "StatusEffectsReceiver"); let cmpTimer = ConstructComponent(SYSTEM_ENTITY, "Timer"); let dealtDamage = 0; let enemyEntity = 4; let enemy = 2; let statusName; let Attacking = { - "HandleAttackEffects": (_, __, attackData) => { - for (let type in attackData.Damage) - dealtDamage += attackData.Damage[type]; + "HandleAttackEffects": (_, data) => { + for (let type in data.attackData.Damage) + dealtDamage += data.attackData.Damage[type]; } }; Engine.RegisterGlobal("Attacking", Attacking); function reset() { for (let status in cmpStatusReceiver.GetActiveStatuses()) cmpStatusReceiver.RemoveStatus(status); dealtDamage = 0; } // Test adding a single effect. statusName = "Burn"; // Damage scheduled: 0, 10, 20 seconds. cmpStatusReceiver.AddStatus(statusName, { "Duration": 20000, "Interval": 10000, "Damage": { [statusName]: 1 } }, { "entity": enemyEntity, "owner": enemy, }); cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 1); // 1 sec cmpTimer.OnUpdate({ "turnLength": 8 }); TS_ASSERT_EQUALS(dealtDamage, 1); // 9 sec cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 2); // 10 sec cmpTimer.OnUpdate({ "turnLength": 10 }); TS_ASSERT_EQUALS(dealtDamage, 3); // 20 sec cmpTimer.OnUpdate({ "turnLength": 10 }); TS_ASSERT_EQUALS(dealtDamage, 3); // 30 sec // Test adding multiple effects. reset(); // Damage scheduled: 0, 1, 2, 10 seconds. cmpStatusReceiver.ApplyStatus({ "Burn": { "Duration": 20000, "Interval": 10000, "Damage": { "Burn": 10 } }, "Poison": { "Duration": 3000, "Interval": 1000, "Damage": { "Poison": 1 } } }); cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 12); // 1 sec cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 13); // 2 sec cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 13); // 3 sec cmpTimer.OnUpdate({ "turnLength": 7 }); TS_ASSERT_EQUALS(dealtDamage, 23); // 10 sec // Test removing a status removes effects. reset(); statusName = "Poison"; // Damage scheduled: 0, 10, 20 seconds. cmpStatusReceiver.AddStatus(statusName, { "Duration": 20000, "Interval": 10000, "Damage": { [statusName]: 1 } }, { "entity": enemyEntity, "owner": enemy, }); cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 1); // 1 sec cmpStatusReceiver.RemoveStatus(statusName); cmpTimer.OnUpdate({ "turnLength": 10 }); TS_ASSERT_EQUALS(dealtDamage, 1); // 11 sec // Test that a status effect with modifications modifies. reset(); AddMock(target, IID_Identity, { "GetClassesList": () => ["AffectedClass"] }); let cmpModifiersManager = ConstructComponent(SYSTEM_ENTITY, "ModifiersManager"); let maxHealth = 100; AddMock(target, IID_Health, { "GetMaxHitpoints": () => ApplyValueModificationsToEntity("Health/Max", maxHealth, target) }); statusName = "Haste"; let factor = 0.5; cmpStatusReceiver.AddStatus(statusName, { "Duration": 5000, "Modifiers": { [statusName]: { "Paths": { "_string": "Health/Max" }, "Affects": { "_string": "AffectedClass" }, "Multiply": factor } } }, { "entity": enemyEntity, "owner": enemy, }); let cmpHealth = Engine.QueryInterface(target, IID_Health); // Test that the modification is applied. TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), maxHealth * factor); cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), maxHealth * factor); // Test that the modification is removed after the appropriate time. cmpTimer.OnUpdate({ "turnLength": 4 }); TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), maxHealth); // Test addition. let addition = 50; cmpStatusReceiver.AddStatus(statusName, { "Duration": 5000, "Modifiers": { [statusName]: { "Paths": { "_string": "Health/Max" }, "Affects": { "_string": "AffectedClass" }, "Add": addition } } }, { "entity": enemyEntity, "owner": enemy, }); // Test that the addition modification is applied. TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), maxHealth + addition); cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), maxHealth + addition); // Test that the modification is removed after the appropriate time. cmpTimer.OnUpdate({ "turnLength": 4 }); TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), maxHealth); // Test replacement. let newValue = 50; cmpStatusReceiver.AddStatus(statusName, { "Duration": 5000, "Modifiers": { [statusName]: { "Paths": { "_string": "Health/Max" }, "Affects": { "_string": "AffectedClass" }, "Replace": newValue } } }, { "entity": enemyEntity, "owner": enemy, }); // Test that the replacement modification is applied. TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), newValue); cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), newValue); // Test that the modification is removed after the appropriate time. cmpTimer.OnUpdate({ "turnLength": 4 }); TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), maxHealth); function applyStatus(stackability) { cmpStatusReceiver.ApplyStatus({ "randomName": { "Duration": 3000, "Interval": 1000, "Damage": { "randomName": 1 }, "Stackability": stackability } }); } // Test different stackabilities. // First ignoring, i.e. next time the same status is added it is just ignored. reset(); applyStatus("Ignore"); // 1 Second: 1 update and lateness. cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 2); applyStatus("Ignore"); // 2 Seconds. cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 3); // 3 Seconds: finished in previous turn. cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 3); // Extending, i.e. next time the same status is applied the times are added. reset(); applyStatus("Extend"); // 1 Second: 1 update and lateness. cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 2); // Add 3 seconds. applyStatus("Extend"); // 2 Seconds. cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 3); // 3 Seconds: extended in previous turn. cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 4); // 4 Seconds. cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 5); // 5 Seconds. cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 6); // 6 Seconds: finished in previous turn (3 + 3). cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 6); // Replacing, i.e. the next applied status replaces the former. reset(); applyStatus("Replace"); // 1 Second: 1 update and lateness. cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 2); applyStatus("Replace"); // 2 Seconds: 1 update and lateness of the new status. cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 4); // 3 Seconds. cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 5); // 4 Seconds: finished in previous turn. cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 5); // Stacking, every new status just applies besides the rest. reset(); applyStatus("Stack"); // 1 Second: 1 update and lateness. cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 2); applyStatus("Stack"); // 2 Seconds: 1 damage from the previous status + 2 from the new (1 turn + lateness). cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 5); // 3 Seconds: first one finished in the previous turn, +1 from the new. cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 6); // 4 Seconds: new status finished in previous turn. cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 6); Index: ps/trunk/binaries/data/mods/public/simulation/helpers/Attacking.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/helpers/Attacking.js (revision 25012) +++ ps/trunk/binaries/data/mods/public/simulation/helpers/Attacking.js (revision 25013) @@ -1,381 +1,383 @@ /** * 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); + data.type += ".Splash"; + this.HandleAttackEffects(ent, data, 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 {Object} data - The data of the attack. + * @param {string} data.type - The type of attack that was performed (e.g. "Melee" or "Capture"). + * @param {Object} data.effectData - The effects use. + * @param {number} data.attacker - The entityID that attacked us. + * @param {number} data.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) +Attacking.prototype.HandleAttackEffects = function(target, data, 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); + bonusMultiplier *= !data.attackData.Bonuses ? 1 : this.GetAttackBonus(data.attacker, target, data.type, data.attackData.Bonuses); let targetState = {}; for (let receiver of g_AttackEffects.Receivers()) { - if (!attackData[receiver.type]) + if (!data.attackData[receiver.type]) continue; let cmpReceiver = Engine.QueryInterface(target, global[receiver.IID]); if (!cmpReceiver) continue; - Object.assign(targetState, cmpReceiver[receiver.method](this.GetTotalAttackEffects(target, attackData, receiver.type, bonusMultiplier, cmpResistance), attacker, attackerOwner)); + Object.assign(targetState, cmpReceiver[receiver.method](this.GetTotalAttackEffects(target, data.attackData, receiver.type, bonusMultiplier, cmpResistance), data.attacker, data.attackerOwner)); } if (!Object.keys(targetState).length) return false; Engine.PostMessage(target, MT_Attacked, { - "type": attackType, + "type": data.type, "target": target, - "attacker": attacker, - "attackerOwner": attackerOwner, + "attacker": data.attacker, + "attackerOwner": data.attackerOwner, "damage": -(targetState.healthChange || 0), "capture": targetState.captureChange || 0, "statusEffects": targetState.inflictedStatuses || [], - "fromStatusEffect": !!attackData.StatusEffect, + "fromStatusEffect": !!data.attackData.StatusEffect, }); // We do not want an entity to get XP from active Status Effects. - if (!!attackData.StatusEffect) + if (!!data.attackData.StatusEffect) return true; - let cmpPromotion = Engine.QueryInterface(attacker, IID_Promotion); + let cmpPromotion = Engine.QueryInterface(data.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 25012) +++ ps/trunk/binaries/data/mods/public/simulation/helpers/tests/test_Attacking.js (revision 25013) @@ -1,159 +1,199 @@ 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); + Attacking.HandleAttackEffects(this.TESTED_ENTITY_ID, { + "type": "Test", + "attackData": this.attackData, + "attacker": INVALID_ENTITY, + "attackerOwner": 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); + Attacking.HandleAttackEffects(this.TESTED_ENTITY_ID, { + "type": "Test", + "attackData": this.attackData, + "attacker": INVALID_ENTITY, + "attackerOwner": 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); + Attacking.HandleAttackEffects(this.TESTED_ENTITY_ID, { + "type": "Test", + "attackData": this.attackData, + "attacker": INVALID_ENTITY, + "attackerOwner": 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); + Attacking.HandleAttackEffects(this.TESTED_ENTITY_ID, { + "type": "Test", + "attackData": this.attackData, + "attacker": INVALID_ENTITY, + "attackerOwner": 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); + Attacking.HandleAttackEffects(this.TESTED_ENTITY_ID, { + "type": "Test", + "attackData": this.attackData, + "attacker": INVALID_ENTITY, + "attackerOwner": 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); + Attacking.HandleAttackEffects(this.TESTED_ENTITY_ID, { + "type": "Test", + "attackData": this.attackData, + "attacker": INVALID_ENTITY, + "attackerOwner": 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); + Attacking.HandleAttackEffects(this.TESTED_ENTITY_ID, { + "type": "Test", + "attackData": this.attackData, + "attacker": INVALID_ENTITY, + "attackerOwner": 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); + Attacking.HandleAttackEffects(this.TESTED_ENTITY_ID, { + "type": "Test", + "attackData": this.attackData, + "attacker": INVALID_ENTITY, + "attackerOwner": INVALID_PLAYER + }, 2); } } new testHandleAttackEffects().testMultipleEffects(); new testHandleAttackEffects().testSkippedEffect(); new testHandleAttackEffects().testAttackedMessage(); new testHandleAttackEffects().testStatusEffects(); new testHandleAttackEffects().testBonusMultiplier();