From 85957670baceb679e4291c833d5c4f0a78746603 Mon Sep 17 00:00:00 2001 From: Fate-JH Date: Mon, 8 Jan 2024 12:59:58 -0500 Subject: [PATCH] Log-Related Fixes (2023-12-5) (#1149) * tighened up the iterative processing aspects of kill assist calculations; wrong database query for assists; assumption of flag being acquired when it really wasn't; assumption of facility capture start when no longer represented * mainly, the spacing * augmented the calculations for bep * adjustment to calculations for the long life bonus experience and to the lifespan experience limits --- src/main/resources/application.conf | 99 +++- .../objects/avatar/PlayerControl.scala | 2 +- .../definition/ExoSuitDefinition.scala | 1 + .../MajorFacilityHackParticipation.scala | 4 +- .../TowerHackParticipation.scala | 4 +- .../objects/vital/InGameHistory.scala | 21 +- .../objects/zones/exp/KillAssists.scala | 275 +++------- .../objects/zones/exp/KillContributions.scala | 4 +- .../psforever/objects/zones/exp/Support.scala | 509 +++++++++++++++++- .../objects/zones/exp/ToDatabase.scala | 10 +- .../local/support/HackCaptureActor.scala | 20 +- .../scala/net/psforever/util/Config.scala | 47 +- 12 files changed, 737 insertions(+), 259 deletions(-) diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index c0fa142b..7cd03a76 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -231,7 +231,7 @@ game { # Battle experience points # BEP is to be calculated in relation to how valuable a kill is worth. bep = { - # After all calculations are complete, multiple the result by this value + # After all calculations are complete, multiply the result by this value rate = 1.0 # These numbers are to determine the starting value for a particular kill base = { @@ -240,12 +240,103 @@ game { # If the player who died ever utilized a mechanized assault exo-suit as-max = 250 # The player who died got at least one kill - with-kills = 100 + with-kills = 150 # The player who died was mounted in a vehicle at the time of death as-mounted = 100 # The player who died after having been in the game world for a while after spawning. # Dying before this is often called a "spawn kill". mature = 50 + # How long it normally takes for a player who has respawned to naturally lose the status of "green" when being inactive. + # See `base.nature`. + maturity-time = 30000 + } + life-span = { + # The experience value of a player's lifespan is measured in intervals. + # Per interval, after all calculations are complete, multiply the result by this value + life-span-threat-rate = 1.0 + # The experience value of using certain equipment per interval of time during playtime (consider to be per second). + # (key, value) where key is technically the index of an ExoSuitType or an object class id and value is the growth + threat-assessment-of = [ + { + id = 0 + value = 1.25 + }, + { + id = 1 + value = 1.5 + }, + { + id = 2 + value = 2.15 + }, + { + id = 3 + value = 1.25 + }, + { + id = 4 + value = 1.0 + }, + { + id = 258 + value = 10.0 + }, + { + id = 410 + value = 0 + }, + { + id = 608 + value = 0 + } + ] + # The maximum experience ceiling during playtime based on the use of certain equipment. + # (key, value) where key is technically the index of an ExoSuitType or an object class id and value is the maximum + max-threat-level = [ + { + id = 0 + level = 2000 + }, + { + id = 1 + level = 2000 + }, + { + id = 2 + level = 5000 + }, + { + id = 3 + level = 2000 + }, + { + id = 4 + level = 900 + }, + { + id = 258 + level = 0 + }, + { + id = 410 + level = 0 + }, + { + id = 608 + level = 0 + } + ] + } + revenge = { + # If player A kills another player B who killed player A just previously, this is the percentage of experience to deposit. + # The kill event must have been the exact previous life's death after revive or respawn. + # This only applies if experience is set to 0. + # Set to zero and experience = 0 to ignore revenge. + rate = 0.15 + # If player A kills another player B who killed player A just previously, deposit this experience. + # The kill event must have been the exact previous life's death after revive or respawn. + # Set to zero to reuse the experience value from the previous kill event. + experience = 0 } } @@ -253,7 +344,7 @@ game { # The events from which support experience rises are numerous. # Calculation is determined by the selection of an "event" that decides how the values are combined. sep = { - # After all calculations are complete, multiple the result by this value + # After all calculations are complete, multiply the result by this value rate = 1.0 # When using an advanced nanite transport to deposit into the resource silo of a major facility, # for reaching the maximum amount of a single deposit, @@ -376,6 +467,8 @@ game { # When the cep has to be capped for squad size, calculate a small amount to add to the capped value squad-size-limit-overflow-multiplier = 0.2 } + # When summing bep to produce facility capture base rewards, multiply the result by this value + facility-capture-rate = 0.5 } # The game's official maximum battle rank is 40. diff --git a/src/main/scala/net/psforever/objects/avatar/PlayerControl.scala b/src/main/scala/net/psforever/objects/avatar/PlayerControl.scala index 8c701ef1..73e20dd6 100644 --- a/src/main/scala/net/psforever/objects/avatar/PlayerControl.scala +++ b/src/main/scala/net/psforever/objects/avatar/PlayerControl.scala @@ -643,7 +643,7 @@ class PlayerControl(player: Player, avatarActor: typed.ActorRef[AvatarActor.Comm player.ExoSuit = exosuit //changes the value of MaxArmor to reflect the new exo-suit val toMaxArmor = player.MaxArmor val toArmor = toMaxArmor - if (originalArmor != toMaxArmor) { + if (originalSuit != exosuit || originalArmor != toMaxArmor) { player.LogActivity(RepairFromExoSuitChange(exosuit, toMaxArmor - originalArmor)) } player.Armor = toMaxArmor diff --git a/src/main/scala/net/psforever/objects/definition/ExoSuitDefinition.scala b/src/main/scala/net/psforever/objects/definition/ExoSuitDefinition.scala index 7a26c32b..3e80289f 100644 --- a/src/main/scala/net/psforever/objects/definition/ExoSuitDefinition.scala +++ b/src/main/scala/net/psforever/objects/definition/ExoSuitDefinition.scala @@ -191,6 +191,7 @@ object ExoSuitDefinition { case PlanetSideEmpire.TR => GlobalDefinitions.TRMAX.Use case PlanetSideEmpire.NC => GlobalDefinitions.NCMAX.Use case PlanetSideEmpire.VS => GlobalDefinitions.VSMAX.Use + case _ => GlobalDefinitions.Standard.Use } case _ => GlobalDefinitions.Standard.Use } diff --git a/src/main/scala/net/psforever/objects/serverobject/structures/participation/MajorFacilityHackParticipation.scala b/src/main/scala/net/psforever/objects/serverobject/structures/participation/MajorFacilityHackParticipation.scala index 1cfaa2c7..05a01682 100644 --- a/src/main/scala/net/psforever/objects/serverobject/structures/participation/MajorFacilityHackParticipation.scala +++ b/src/main/scala/net/psforever/objects/serverobject/structures/participation/MajorFacilityHackParticipation.scala @@ -110,10 +110,10 @@ final case class MajorFacilityHackParticipation(building: Building) extends Faci ) //1) experience from killing opposingFaction across duration of hack //The kills that occurred in the facility's attached field tower's sphere of influence have been eliminated from consideration. - val baseExperienceFromFacilityCapture: Long = FacilityHackParticipation.calculateExperienceFromKills( + val baseExperienceFromFacilityCapture: Long = (FacilityHackParticipation.calculateExperienceFromKills( killsByPlayersNotInTower, contributionOpposingSize - ) + ) * Config.app.game.experience.facilityCaptureRate).toLong val events = building.Zone.AvatarEvents val buildingId = building.GUID.guid val zoneNumber = building.Zone.Number diff --git a/src/main/scala/net/psforever/objects/serverobject/structures/participation/TowerHackParticipation.scala b/src/main/scala/net/psforever/objects/serverobject/structures/participation/TowerHackParticipation.scala index 4f591680..b780c749 100644 --- a/src/main/scala/net/psforever/objects/serverobject/structures/participation/TowerHackParticipation.scala +++ b/src/main/scala/net/psforever/objects/serverobject/structures/participation/TowerHackParticipation.scala @@ -50,7 +50,7 @@ final case class TowerHackParticipation(building: Building) extends FacilityHack val hackerId = hacker.CharId //1) experience from killing opposingFaction //Because the hack duration of towers is instantaneous, the prior period of five minutes is artificially selected. - val baseExperienceFromFacilityCapture: Long = FacilityHackParticipation.calculateExperienceFromKills( + val baseExperienceFromFacilityCapture: Long = (FacilityHackParticipation.calculateExperienceFromKills( FacilityHackParticipation.allocateKillsByPlayers( building.Position, building.Definition.SOIRadius.toFloat, @@ -60,7 +60,7 @@ final case class TowerHackParticipation(building: Building) extends FacilityHack contributionVictor ), contributionOpposingSize - ) + ) * Config.app.game.experience.facilityCaptureRate).toLong //based on this math, the optimal number of enemy for experience gain is 20 //max value of: 1000 * pop * max(0, (40 - pop)) * 0.1 if (baseExperienceFromFacilityCapture > 0) { diff --git a/src/main/scala/net/psforever/objects/vital/InGameHistory.scala b/src/main/scala/net/psforever/objects/vital/InGameHistory.scala index 2e96aeb6..d6299097 100644 --- a/src/main/scala/net/psforever/objects/vital/InGameHistory.scala +++ b/src/main/scala/net/psforever/objects/vital/InGameHistory.scala @@ -50,13 +50,28 @@ trait SupportActivityCausedByAnother { def amount: Int } +trait ExoSuitChange { + def exosuit: ExoSuitType.Value +} + +trait CommonExoSuitChange extends ExoSuitChange { + def src: SourceEntry + + def exosuit: ExoSuitType.Value = { + src match { + case p: PlayerSource => p.ExoSuit + case _ => ExoSuitType.Standard + } + } +} + trait IncarnationActivity extends GeneralActivity final case class SpawningActivity(src: SourceEntry, zoneNumber: Int, unit: Option[SourceEntry]) - extends IncarnationActivity + extends IncarnationActivity with CommonExoSuitChange final case class ReconstructionActivity(src: SourceEntry, zoneNumber: Int, unit: Option[SourceEntry]) - extends IncarnationActivity + extends IncarnationActivity with CommonExoSuitChange final case class RevivingActivity(target: SourceEntry, user: PlayerSource, amount: Int, equipment: EquipmentDefinition) extends IncarnationActivity with SupportActivityCausedByAnother @@ -154,7 +169,7 @@ final case class HealFromImplant(implant: ImplantType, amount: Int) extends HealingActivity final case class RepairFromExoSuitChange(exosuit: ExoSuitType.Value, amount: Int) - extends RepairingActivity + extends RepairingActivity with ExoSuitChange final case class RepairFromKit(kit_def: KitDefinition, amount: Int) extends RepairingActivity() diff --git a/src/main/scala/net/psforever/objects/zones/exp/KillAssists.scala b/src/main/scala/net/psforever/objects/zones/exp/KillAssists.scala index 6c71e2ff..46390d20 100644 --- a/src/main/scala/net/psforever/objects/zones/exp/KillAssists.scala +++ b/src/main/scala/net/psforever/objects/zones/exp/KillAssists.scala @@ -8,8 +8,8 @@ import net.psforever.objects.vital.interaction.{Adversarial, DamageResult} import net.psforever.objects.vital.{DamagingActivity, HealingActivity, InGameActivity, RepairingActivity, RevivingActivity, SpawningActivity} import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage} import net.psforever.types.PlanetSideEmpire +import net.psforever.util.Config -import scala.annotation.tailrec import scala.collection.mutable import scala.concurrent.duration._ @@ -145,161 +145,11 @@ object KillAssists { .orElse { limitHistoryToThisLife(history) .lastOption - .collect { case dam: DamagingActivity => - val res = dam.data - (res, res.adversarial.get.attacker) - } + .collect { case dam: DamagingActivity if dam.data.adversarial.nonEmpty => dam.data } + .map { data => (data, data.adversarial.get.attacker) } } } - /** - * "Menace" is a crude measurement of how much consistent destructive power a player has been demonstrating. - * Within the last ten kills, the rate of the player's killing speed is measured. - * The measurement - a "streak" in modern lingo - is transformed into the form of an `Integer` for simplicity. - * @param player the player - * @param mercy a time value that can be used to continue a missed streak - * @return an integer between 0 and 7; - * 0 is no kills, - * 1 is some kills, - * 2-7 is a menace score; - * there is no particular meaning behind different menace scores ascribed by this function - * but the range allows for progressive distinction - * @see `qualifiedTimeDifferences` - * @see `takeWhileLess` - */ - private[exp] def calculateMenace(player: PlayerSource, mercy: Long = 5000L): Int = { - val maxDelayDiff: Long = 45000L - val minDelayDiff: Long = 20000L - val allKills = player.progress.kills - //the very first kill must have been within the max delay (but does not count towards menace) - if (allKills.headOption.exists { System.currentTimeMillis() - _.time.toDate.getTime < maxDelayDiff}) { - allKills match { - case _ :: kills if kills.size > 3 => - val (continuations, restsBetweenKills) = - qualifiedTimeDifferences( - kills.map(_.time.toDate.getTime).iterator, - maxValidDiffCount = 10, - maxDelayDiff, - minDelayDiff - ) - .partition(_ > minDelayDiff) - math.max( - 1, - math.floor(math.sqrt( - math.max(0, takeWhileLess(restsBetweenKills, testValue = 20000L, mercy).size - 1) + /*max=8*/ - math.max(0, takeWhileLess(restsBetweenKills, testValue = 10000L, mercy).size - 5) * 3 + /*max=12*/ - math.max(0, takeWhileLess(restsBetweenKills, testValue = 5000L, mercy = 1000L).size - 4) * 7 /*max=35*/ - ) - continuations.size) - ).toInt - case _ => - 1 - } - } else { - 0 - } - } - - /** - * Take a list of times - * and produce a list of delays between those entries less than a maximum time delay. - * These are considered "qualifying". - * Count a certain number of time delays that fall within a minimum threshold - * and stop when that minimum count is achieved. - * These are considered "valid". - * The final product should be a new list of the successive delays from the first list - * containing both qualified and valid entries, - * stopping at either the first unqualified delay or the last valid delay or at exhaustion of the original list. - * @param iter unfiltered list of times (ms) - * @param maxValidDiffCount maximum number of valid entries in the final list of time differences; - * see `validTimeEntryCount` - * @param maxDiff exclusive amount of time allowed between qualifying entries; - * include any time difference within this delay; - * these entries are "qualifying" but are not "valid" - * @param minDiff inclusive amount of time difference allowed between valid entries; - * include time differences in this delay - * these entries are "valid" and should increment the counter `validTimeEntryCount` - * @return list of qualifying time differences (ms) - */ - /* - Parameters governed by recursion: - @param diffList ongoing list of qualifying time differences (ms) - @param diffExtensionList accumulation of entries greater than the `minTimeEntryDiff` - but less that the `minTimeEntryDiff`; - holds qualifying time differences - that will be included before the next valid time difference - @param validDiffCount currently number of valid time entries in the qualified time list; - see `maxValidTimeEntryCount` - @param previousTime previous qualifying entry time; - by default, current time (ms) - */ - @tailrec - private def qualifiedTimeDifferences( - iter: Iterator[Long], - maxValidDiffCount: Int, - maxDiff: Long, - minDiff: Long, - diffList: Seq[Long] = Nil, - diffExtensionList: Seq[Long] = Nil, - validDiffCount: Int = 0, - previousTime: Long = System.currentTimeMillis() - ): Iterable[Long] = { - if (iter.hasNext && validDiffCount < maxValidDiffCount) { - val nextTime = iter.next() - val delay = previousTime - nextTime - if (delay < maxDiff) { - if (delay <= minDiff) { - qualifiedTimeDifferences( - iter, - maxValidDiffCount, - maxDiff, - minDiff, - diffList ++ (diffExtensionList :+ delay), - Nil, - validDiffCount + 1, - nextTime - ) - } else { - qualifiedTimeDifferences( - iter, - maxValidDiffCount, - maxDiff, - minDiff, - diffList, - diffExtensionList :+ delay, - validDiffCount, - nextTime - ) - } - } else { - diffList - } - } else { - diffList - } - } - - /** - * From a list of values, isolate all values less than than a test value. - * @param list list of values - * @param testValue test value that all valid values must be less than - * @param mercy initial mercy value that values may be tested for being less than the test value - * @return list of values less than the test value, including mercy - */ - private def takeWhileLess(list: Iterable[Long], testValue: Long, mercy: Long): Iterable[Long] = { - var onGoingMercy: Long = mercy - list.filter { value => - if (value < testValue) { - true - } else if (value - onGoingMercy < testValue) { - //mercy is reduced every time it is utilized to find a valid value - onGoingMercy = math.ceil(onGoingMercy * 0.8f).toLong - true - } else { - false - } - } - } - /** * Modify a base experience value to consider additional reasons for points. * @param killer player that delivers the interaction that reduces health to zero @@ -308,39 +158,50 @@ object KillAssists { * @return the value of the kill in what the game called "battle experience points" * @see `BattleRank.withExperience` * @see `Support.baseExperience` + * @see `Support.calculateMenace` */ private def calculateExperience( killer: PlayerSource, victim: PlayerSource, history: Iterable[InGameActivity] ): Long = { - //base value (the kill experience before modifiers) - lazy val base = Support.baseExperience(victim, history) - if (killer.Faction == victim.Faction || killer.unique == victim.unique) { + val killerUnique = killer.unique + if (killer.Faction == victim.Faction || killerUnique == victim.unique) { 0L - } else if (base > 1) { - //include battle rank disparity modifier - val battleRankDisparity: Long = { - import net.psforever.objects.avatar.BattleRank - val killerLevel = BattleRank.withExperience(killer.bep).value - val victimLevel = BattleRank.withExperience(victim.bep).value - val victimMinusKiller = victimLevel - killerLevel - if (victimMinusKiller > -1) { - victimMinusKiller * 10 + victimLevel - } else { - val bothLevels = killerLevel + victimLevel - val pointFive = (base.toFloat * 0.25f).toInt - -1 * (if (bothLevels >= base) { - pointFive - } else { - math.min(bothLevels, pointFive) - }) - } - }.toLong - //include menace modifier - base + battleRankDisparity + (victim.progress.kills.size.toFloat * (1f + calculateMenace(victim).toFloat / 10f)).toLong } else { - base + val base = Support.baseExperience(victim, history) + if (base > Support.TheShortestLifeIsWorth) { + val bep = Config.app.game.experience.bep + //battle rank disparity modifier + val battleRankDisparity: Long = { + import net.psforever.objects.avatar.BattleRank + val killerLevel = BattleRank.withExperience(killer.bep).value + val victimLevel = BattleRank.withExperience(victim.bep).value + val victimMinusKiller = victimLevel - killerLevel + if (victimMinusKiller > -1) { + victimMinusKiller * 5 + victimLevel // 5 x (y - x) + y -> min = 1 @ (1, 1), max = 235 @ (40, 1) + } else { + victimMinusKiller //min = -39, max = -1 + } + }.toLong + //revenge modifier + val revengeBonus: Long = { + val revenge = bep.revenge + val victimUnique = victim.unique + val lastDeath = killer.progress.prior.flatMap(_.death) + val sameLastKiller = lastDeath.map(_.assailant.map(_.unique)).flatMap(_.headOption).contains(victimUnique) + if (revenge.experience != 0 && sameLastKiller) { + revenge.experience + } else if (sameLastKiller) { + (lastDeath.map(_.experienceEarned).get * revenge.rate).toLong + } else { + 0L + } + } + base + battleRankDisparity + revengeBonus + } else { + base + } } } @@ -600,37 +461,49 @@ object KillAssists { ): Seq[(Long, Int)] = { var amt = amount var count = 0 - var newOrder: Seq[(Long, Int)] = Nil + var newOrderPos: Seq[(Long, Int)] = Nil + var newOrderNeg: Seq[(Long, Int)] = Nil order.takeWhile { entry => val (id, total) = entry if (id > 0 && total > 0) { - val part = participants(id) - if (amount > total) { - //drop this entry - participants.put(id, part.copy(amount = 0, weapons = Nil)) //just in case - amt = amt - total - } else { - //edit around the inclusion of this entry - val newTotal = total - amt - val trimmedWeapons = { - var index = -1 - var weaponSum = 0 - val pweapons = part.weapons - while (weaponSum < amt) { - index += 1 - weaponSum = weaponSum + pweapons(index).amount + participants.get(id) match { + case Some(part) => + val reduceByValue = math.min(amt, total) + val trimmedWeapons = { + var index = 0 + var weaponSum = 0 + val pweapons = part.weapons + val pwepiter = pweapons.iterator + while (pwepiter.hasNext && weaponSum < reduceByValue) { + weaponSum = weaponSum + pwepiter.next().amount + index += 1 + } + //output list(s) + (if (weaponSum == reduceByValue) { + newOrderNeg = newOrderNeg :+ (id, 0) + index += 1 + pweapons.drop(index) + } else if (weaponSum > reduceByValue) { + newOrderPos = (id, total - amt) +: newOrderPos + val remainder = pweapons.drop(index) + remainder.headOption.map(_.copy(amount = weaponSum - reduceByValue)) ++ remainder.tail + } else { + newOrderPos = (id, total - amt) +: newOrderPos + val remainder = pweapons.drop(index) + remainder.headOption.map(_.copy(amount = reduceByValue - weaponSum)) ++ remainder.tail + }) ++ pweapons.take(index).map(_.copy(amount = 0)) } - (pweapons(index).copy(amount = weaponSum - amt) +: pweapons.slice(index+1, pweapons.size)) ++ - pweapons.slice(0, index).map(_.copy(amount = 0)) - } - newOrder = (id, newTotal) +: newOrder - participants.put(id, part.copy(amount = part.amount - amount, weapons = trimmedWeapons)) - amt = 0 + participants.put(id, part.copy(amount = part.amount - reduceByValue, weapons = trimmedWeapons.toSeq)) + amt = amt - reduceByValue + case _ => + //we do not have contribution stat data for this id + //perform no calculations; devalue the entry + newOrderNeg = newOrderNeg :+ (id, 0) } } count += 1 amt > 0 } - newOrder ++ order.drop(count) + newOrderPos ++ order.drop(count) ++ newOrderNeg//.reverse } } diff --git a/src/main/scala/net/psforever/objects/zones/exp/KillContributions.scala b/src/main/scala/net/psforever/objects/zones/exp/KillContributions.scala index 93293aaa..410492f9 100644 --- a/src/main/scala/net/psforever/objects/zones/exp/KillContributions.scala +++ b/src/main/scala/net/psforever/objects/zones/exp/KillContributions.scala @@ -107,7 +107,7 @@ object KillContributions { * @see `CombinedHealthAndArmorContributionProcess` * @see `composeContributionOutput` * @see `initialScoring` - * @see `KillAssists.calculateMenace` + * @see `Support.calculateMenace` * @see `limitHistoryToThisLife` * @see `rewardTheseSupporters` * @see `SupportActivity` @@ -140,7 +140,7 @@ object KillContributions { val empty = mutable.ListBuffer[SourceUniqueness]() empty.addOne(target.unique) val otherContributionCalculations = additionalContributionSources(faction, kill, contributions)(_, _, _) - if (longHistory.nonEmpty && KillAssists.calculateMenace(target) > 3) { + if (longHistory.nonEmpty && Support.calculateMenace(target) > 3) { //long and short history val longContributionProcess = new CombinedHealthAndArmorContributionProcess(faction, contributions, Nil) val shortContributionProcess = new CombinedHealthAndArmorContributionProcess(faction, contributions, Seq(longContributionProcess)) diff --git a/src/main/scala/net/psforever/objects/zones/exp/Support.scala b/src/main/scala/net/psforever/objects/zones/exp/Support.scala index a4fd65a7..624225d7 100644 --- a/src/main/scala/net/psforever/objects/zones/exp/Support.scala +++ b/src/main/scala/net/psforever/objects/zones/exp/Support.scala @@ -2,49 +2,510 @@ package net.psforever.objects.zones.exp import net.psforever.objects.sourcing.PlayerSource -import net.psforever.objects.vital.{InGameActivity, ReconstructionActivity, RepairFromExoSuitChange, SpawningActivity} +import net.psforever.objects.vital.{ExoSuitChange, InGameActivity, RevivingActivity, TerminalUsedActivity, VehicleDismountActivity, VehicleMountActivity, VehicleMountChange, VitalityDefinition} import net.psforever.types.{ExoSuitType, PlanetSideEmpire} -import net.psforever.util.Config +import net.psforever.util.{Config, DefinitionUtil, ThreatAssessment, ThreatLevel} +import scala.annotation.tailrec import scala.collection.mutable /** * Functions to assist experience calculation and history manipulation and analysis. */ object Support { - private val sep = Config.app.game.experience.sep + /** Almost nothing! */ + final val TheShortestLifeIsWorth: Long = 1L /** - * Calculate a base experience value to consider additional reasons for points. + * Calculate the experience value to reflect the value of a player's lifespan. * @param victim player to which a final interaction has reduced health to zero * @param history chronology of activity the game considers noteworthy * @return the value of the kill in what the game called "battle experience points" - * @see `Support.wasEverAMax` */ private[exp] def baseExperience( victim: PlayerSource, history: Iterable[InGameActivity] ): Long = { - val lifespan = (history.headOption, history.lastOption) match { + //setup + val historyList = history.toList + val withKills = victim.progress.kills.nonEmpty + val fullLifespan = (historyList.headOption, historyList.lastOption) match { case (Some(spawn), Some(death)) => death.time - spawn.time case _ => 0L } - val base = if (Support.wasEverAMax(victim, history)) { - Config.app.game.experience.bep.base.asMax - } else if (victim.progress.kills.nonEmpty) { - Config.app.game.experience.bep.base.withKills - } else if (victim.Seated) { - Config.app.game.experience.bep.base.asMounted - } else if (lifespan > 15000L) { - Config.app.game.experience.bep.base.mature + val recordOfWornTimes = countTimeWhileExoSuitOrMounted(historyList) + .map { case (id, time) => (id, (time * 0.001f).toLong) } // turn milliseconds into seconds + //short life factors + val shortLifeBonus = baseExperienceShortLifeFactors( + victim, + historyList, + recordOfWornTimes, + withKills, + fullLifespan + ) + if (shortLifeBonus > TheShortestLifeIsWorth) { + val longLifeBonus: Long = { + val threat = baseExperienceLongLifeFactors(victim, recordOfWornTimes, defaultValue = 100f * shortLifeBonus.toFloat) + if (withKills) { + threat + } else { + (threat * 0.85f).toLong + } + } + //long life factors + shortLifeBonus + longLifeBonus } else { - 1L + //the shortest life is afforded no additional bonuses + shortLifeBonus } - if (base > 1) { - //black ops modifier - base// * Config.app.game.experience.bep.base.bopsMultiplier + } + + /** + * Assuming a chronological history of player actions and interactions, + * allocate every exo-suit use and mountable use to a time interval + * and accumulates the sum of those time intervals. + * The end result is a map association between exo-suits and vehicles and time that equipment has been used. + * @param history chronology of activity the game considers noteworthy + * @param initialExosuit start with this exo-suit type + * @return mapping between equipment (object class ids) and the time that equipment has been used (ms); + * the "equipment" includes exo-suits and all noted mountable entities + */ + private def countTimeWhileExoSuitOrMounted( + history: List[InGameActivity], + initialExosuit: ExoSuitType.Value = ExoSuitType.Standard + ): Map[Int, Long] = { + val wornTime: mutable.HashMap[Int, Long] = mutable.HashMap[Int, Long]() + var currentSuit: Int = initialExosuit.id + var lastActTime: Long = history.head.time + var lastMountAct: Option[VehicleMountChange] = None + //collect history events that encompass changes to exo-suits and to mounting conditions + history.collect { + case suitChange: ExoSuitChange => + updateEquippedEntry( + currentSuit, + suitChange.time - lastActTime, + wornTime + ) + currentSuit = suitChange.exosuit.id + lastActTime = suitChange.time + case mount: VehicleMountActivity => + updateEquippedEntry( + currentSuit, + mount.time - lastActTime, + wornTime + ) + lastActTime = mount.time + lastMountAct = Some(mount) + case dismount: VehicleDismountActivity + if dismount.pairedEvent.isEmpty => + updateEquippedEntry( + dismount.vehicle.Definition.ObjectId, + dismount.time - lastActTime, + wornTime + ) + lastActTime = dismount.time + lastMountAct = None + case dismount: VehicleDismountActivity => + updateEquippedEntry( + dismount.vehicle.Definition.ObjectId, + dismount.time - dismount.pairedEvent.get.time, + wornTime + ) + lastActTime = dismount.time + lastMountAct = None + } + //no more changes; add remaining time from unresolved activity + val lastTime = history.last.time + lastMountAct + .collect { mount => + //dying in a vehicle is a reason to care about the last mount activity + updateEquippedEntry( + mount.vehicle.Definition.ObjectId, + lastTime - mount.time, + wornTime + ) + Some(mount) + } + .orElse { + //dying while on foot + updateEquippedEntry( + currentSuit, + lastTime - lastActTime, + wornTime + ) + None + } + wornTime.toMap + } + + /** + * ... + * @param equipmentId the equipment + * @param timePassed how long it was in use + * @param wornTime mapping between equipment (object class ids) and the time that equipment has been used (ms) + * @return the length of time the equipment was used + */ + private def updateEquippedEntry( + equipmentId: Int, + timePassed: Long, + wornTime: mutable.HashMap[Int, Long] + ): Long = { + wornTime + .get(equipmentId) + .collect { + oldTime => + val time = oldTime + timePassed + wornTime.update(equipmentId, time) + time + } + .orElse { + wornTime.update(equipmentId, timePassed) + Some(timePassed) + } + .get + } + + /** + * Calculate the experience value to reflect the value of a player's short term lifespan. + * In effect, determine a token experience value for short unproductive lives. + * Four main conditions are outlined. + * In order of elimination traversal: + * was the player ever using a mechanized assault exo-suit, + * did the player kill anything, + * was the player mounted in a vehicle of turret for long enough for it to be considered, + * and has the player been alive long enough? + * @param player player to which a final interaction has reduced health to zero + * @param history chronology of activity the game considers noteworthy + * @param recordOfWornTimes between equipment (object class ids) and the time that equipment has been used (ms) + * @param withKills consider that the victim killed an opponent in this past life + * @param fullLifespan for how long this last life spanned + * @return the value of the kill in what the game called "battle experience points" + * @see `Config.app.game.experience.bep.base` + */ + private def baseExperienceShortLifeFactors( + player: PlayerSource, + history: List[InGameActivity], + recordOfWornTimes: Map[Int, Long], + withKills: Boolean, + fullLifespan: Long + ): Long = { + val bep = Config.app.game.experience.bep.base + //TODO bops + if (recordOfWornTimes.getOrElse(ExoSuitType.MAX.id, 0L) > 0L) { //see: Support.wasEverAMax + bep.asMax + } else if (withKills) { + bep.withKills + } else if (player.Seated || { + val mountTime = recordOfWornTimes.collect { case (id, value) if id > 10 => value }.sum + mountTime * 3L >= fullLifespan + }) { + bep.asMounted } else { - base + val validMaturityTime = if ( + !history.head.isInstanceOf[RevivingActivity] || + history.exists(_.isInstanceOf[TerminalUsedActivity]) + ) { + bep.maturityTime + } else { + 0L + } + if (fullLifespan > validMaturityTime) { + bep.mature + } else { + TheShortestLifeIsWorth + } + } + } + + /** + * Calculate the experience value to reflect the value of a player's full lifespan. + * A lifespan is associated with conditions and states that can each be assigned a weight or value. + * Summing up all of these conditions and states produces a reward value. + * @param player player to which a final interaction has reduced health to zero + * @param recordOfWornTimes between equipment (object class ids) and the time that equipment has been used (ms) + * @return the value of the kill in what the game called "battle experience points" + * @see `Config.app.game.experience.bep.lifeSpanThreatRate` + * @see `Config.app.game.experience.bep.threatAssessmentOf` + */ + private def baseExperienceLongLifeFactors( + player: PlayerSource, + recordOfWornTimes: Map[Int, Long], + defaultValue: Float + ): Long = { + //awarded values for a target's lifespan based on the distribution of their tactical choices + val individualThreatEstimates: Map[Int, Float] = calculateThreatEstimatesPerEntry(recordOfWornTimes) + val totalThreatEstimate: Float = individualThreatEstimates.values.sum + val maxThreatCapacity: Float = { + val (exosuitTimes, otherTimes) = recordOfWornTimes.partition(_._1 < 10) + calculateMaxThreatCapacityPerEntry( + (if (exosuitTimes.values.sum > otherTimes.values.sum) { + individualThreatEstimates.filter(_._1 < 10) + } else { + individualThreatEstimates.filter(_._1 > 10) + }).maxBy(_._2)._1, + defaultValue + ) + } + //menace modifier -> min = kills, max = 8 x kills + val menace = (player.progress.kills.size.toFloat * (1f + Support.calculateMenace(player).toFloat)).toLong + //last kill experience + val lastKillExperience = player.progress.kills + .lastOption + .collect { kill => + val reduce = ((System.currentTimeMillis() - kill.time.toDate.getTime).toFloat * 0.001f).toLong + math.max(0L, kill.experienceEarned - reduce) + } + .getOrElse(0L) + //cap lifespan then add extra + math.min(totalThreatEstimate, maxThreatCapacity).toLong + menace + lastKillExperience + } + + /** + * Calculate the reward available based on a tactical option by id. + * @param recordOfWornTimes between equipment (object class ids) and the time that equipment has been used (ms) + * @return value of the equipment + */ + private def calculateThreatEstimatesPerEntry(recordOfWornTimes: Map[Int, Long]): Map[Int, Float] = { + recordOfWornTimes.map { + case (key, amount) => (key, amount * calculateThreatEstimatesPerEntry(key)) + } + } + + /** + * Calculate the reward available based on a tactical option by id. + * If not listed in a previous table of values, + * obtain the definition associated with the equipment id and test use the mass of the entity. + * The default value is 0. + * @param key equipment id used to collect the ceiling value + * @return value of the equipment + * @see `Config.app.game.experience.bep.threatAssessmentOf` + * @see `VitalityDefinition.mass` + */ + private def calculateThreatEstimatesPerEntry(key: Int): Float = { + Config.app.game.experience.bep.lifeSpan.threatAssessmentOf + .find { case ThreatAssessment(a, _) => a == key } + .map(_.value) + .getOrElse { + getDefinitionById(key) + .map(o => 2f + math.log10(o.mass.toDouble).toFloat) + .getOrElse(0f) + } + } + + + /** + * Calculate the maximum possible reward available based on tactical options. + * If not listed in a previous table of values, + * obtain the definition associated with the equipment id and test use the maximum health of the entity. + * @param key equipment id used to estimate one sample for the ceiling value + * @param defaultValue what to use for an unresolved ceiling value; + * defaults to 0 + * @return maximum value for this equipment + * @see `Config.app.game.experience.bep.maxThreatLevel` + * @see `VitalityDefinition.MaxHealth` + */ + private def calculateMaxThreatCapacityPerEntry( + key: Int, + defaultValue: Float + ): Float = { + Config.app.game.experience.bep.lifeSpan.maxThreatLevel + .find { case ThreatLevel(a, _) => a == key } + .map(_.level.toFloat) + .getOrElse { + getDefinitionById(key) + .map(_.MaxHealth.toFloat * 1.2f) + .getOrElse(defaultValue) + } + } + + /** + * ... + * @param key equipment id + * @return the definition if the definition can be found; + * `None`, otherwise + * @see `DefinitionUtil.idToDefinition` + * @see `GlobalDefinitions` + * @see `VitalityDefinition` + */ + private def getDefinitionById(key: Int): Option[VitalityDefinition] = { + try { + DefinitionUtil.idToDefinition(key) match { + case o: VitalityDefinition => Some(o) + case _ => None + } + } catch { + case _: Exception => None + } + } + + /** + * "Menace" is a crude measurement of how much consistent destructive power a player has been demonstrating. + * Within the last few kills, the rate of the player's killing speed is measured. + * The measurement - a "streak" in modern lingo - is transformed into the form of an `Integer` for simplicity. + * @param player the player + * @param minimumKills number of kills needed before menace is considered + * @param testValues time values to determine allowable delay between kills to qualify for a score rating; + * three score ratings, so three values; + * defaults to 20s, 10s, 5s (in ms) + * @param maxDelayDiff time until the previous kill disqualifies menace; + * exclusive amount of time allowed between qualifying entries; + * default is 45s (in ms) + * @param minDelayDiff inclusive amount of time difference allowed between valid entries; + * default is 20s (in ms) + * @param mercy a time value that can be used to continue a missed streak; + * defaults to 5s (in ms) + * @return an integer between 0 and 7; + * 0 is no kills, + * 1 is some kills, + * 2-7 is a menace score; + * there is no particular meaning behind different menace scores ascribed by this function + * but the range allows for progressive distinction + * @see `qualifiedTimeDifferences` + * @see `takeWhileLess` + */ + private[exp] def calculateMenace( + player: PlayerSource, + minimumKills: Int = 3, + testValues: Seq[Long] = Seq(20000L, 10000L, 5000L), + maxDelayDiff: Long = 45000L, + minDelayDiff: Long = 20000L, + mercy: Long = 5000L + ): Int = { + //init + val (minDiff, maxDiff) = (math.min(maxDelayDiff, maxDelayDiff), math.max(maxDelayDiff, maxDelayDiff)) + val valuesForTesting = testValues.padTo(3, ((maxDiff + minDiff) * 0.5f).toLong) + //func + val allKills = player.progress.kills + //the very first kill must have been within the max delay (but does not count towards menace) + if (allKills.headOption.exists { System.currentTimeMillis() - _.time.toDate.getTime < maxDiff}) { + allKills match { + case _ :: kills if kills.size > minimumKills => + val (continuations, restsBetweenKills) = + qualifiedTimeDifferences( + kills.map(_.time.toDate.getTime).iterator, + maxValidDiffCount = 10, + maxDiff, + minDiff + ) + .partition(_ > minDiff) + math.max( + 1, + math.floor(math.sqrt( + math.max(0, takeWhileLess(restsBetweenKills, valuesForTesting.head, mercy).size - 1) + /*max=8*/ + math.max(0, takeWhileLess(restsBetweenKills, valuesForTesting(1), mercy).size - 5) * 3 + /*max=12*/ + math.max(0, takeWhileLess(restsBetweenKills, valuesForTesting(2), mercy = 1000L).size - 4) * 7 /*max=35*/ + ) - continuations.size) + ).toInt + case _ => + 1 + } + } else { + 0 + } + } + + /** + * Take a list of times + * and produce a list of delays between those entries less than a maximum time delay. + * These are considered "qualifying". + * Count a certain number of time delays that fall within a minimum threshold + * and stop when that minimum count is achieved. + * These are considered "valid". + * The final product should be a new list of the successive delays from the first list + * containing both qualified and valid entries, + * stopping at either the first unqualified delay or the last valid delay or at exhaustion of the original list. + * @param iter unfiltered list of times (ms) + * @param maxValidDiffCount maximum number of valid entries in the final list of time differences; + * see `validTimeEntryCount` + * @param maxDiff exclusive amount of time allowed between qualifying entries; + * include any time difference within this delay; + * these entries are "qualifying" but are not "valid" + * @param minDiff inclusive amount of time difference allowed between valid entries; + * include time differences in this delay + * these entries are "valid" and should increment the counter `validTimeEntryCount` + * @return list of qualifying time differences (ms) + */ + /* + Parameters governed by recursion: + @param diffList ongoing list of qualifying time differences (ms) + @param diffExtensionList accumulation of entries greater than the `minTimeEntryDiff` + but less that the `minTimeEntryDiff`; + holds qualifying time differences + that will be included before the next valid time difference + @param validDiffCount currently number of valid time entries in the qualified time list; + see `maxValidTimeEntryCount` + @param previousTime previous qualifying entry time; + by default, current time (ms) + */ + @tailrec + private def qualifiedTimeDifferences( + iter: Iterator[Long], + maxValidDiffCount: Int, + maxDiff: Long, + minDiff: Long, + diffList: Seq[Long] = Nil, + diffExtensionList: Seq[Long] = Nil, + validDiffCount: Int = 0, + previousTime: Long = System.currentTimeMillis() + ): Iterable[Long] = { + if (iter.hasNext && validDiffCount < maxValidDiffCount) { + val nextTime = iter.next() + val delay = previousTime - nextTime + if (delay < maxDiff) { + if (delay <= minDiff) { + qualifiedTimeDifferences( + iter, + maxValidDiffCount, + maxDiff, + minDiff, + diffList ++ (diffExtensionList :+ delay), + Nil, + validDiffCount + 1, + nextTime + ) + } else { + qualifiedTimeDifferences( + iter, + maxValidDiffCount, + maxDiff, + minDiff, + diffList, + diffExtensionList :+ delay, + validDiffCount, + nextTime + ) + } + } else { + diffList + } + } else { + diffList + } + } + + /** + * From a list of values, isolate all values less than than a test value. + * @param list list of values + * @param testValue test value that all valid values must be less than + * @param mercy initial mercy value that values may be tested against being less than the test value + * @return list of values less than the test value, including mercy + */ + private def takeWhileLess( + list: Iterable[Long], + testValue: Long, + mercy: Long + ): Iterable[Long] = { + var onGoingMercy: Long = mercy + list.filter { value => + if (value < testValue) { + true + } else if (value - onGoingMercy < testValue) { + //mercy is reduced every time it is utilized to find a valid value + onGoingMercy = math.ceil(onGoingMercy * 0.8f).toLong + true + } else { + false + } } } @@ -185,10 +646,8 @@ object Support { */ private[exp] def wasEverAMax(player: PlayerSource, history: Iterable[InGameActivity]): Boolean = { player.ExoSuit == ExoSuitType.MAX || history.exists { - case SpawningActivity(p: PlayerSource, _, _) => p.ExoSuit == ExoSuitType.MAX - case ReconstructionActivity(p: PlayerSource, _, _) => p.ExoSuit == ExoSuitType.MAX - case RepairFromExoSuitChange(suit, _) => suit == ExoSuitType.MAX - case _ => false + case suitChange: ExoSuitChange => suitChange.exosuit == ExoSuitType.MAX + case _ => false } } @@ -211,7 +670,7 @@ object Support { weaponStat: WeaponStats, canNotFindEventDefaultValue: Option[Float] = None ): WeaponStats = { - val rewards: Float = sep.events + val rewards: Float = Config.app.game.experience.sep.events .find(evt => event.equals(evt.name)) .map { event => val shots = weaponStat.shots @@ -228,7 +687,7 @@ object Support { } } .getOrElse( - canNotFindEventDefaultValue.getOrElse(sep.canNotFindEventDefaultValue.toFloat) + canNotFindEventDefaultValue.getOrElse(Config.app.game.experience.sep.canNotFindEventDefaultValue.toFloat) ) weaponStat.copy(contributions = rewards) } diff --git a/src/main/scala/net/psforever/objects/zones/exp/ToDatabase.scala b/src/main/scala/net/psforever/objects/zones/exp/ToDatabase.scala index 33162ea4..aca7a2fe 100644 --- a/src/main/scala/net/psforever/objects/zones/exp/ToDatabase.scala +++ b/src/main/scala/net/psforever/objects/zones/exp/ToDatabase.scala @@ -54,7 +54,7 @@ object ToDatabase { position: Vector3, exp: Long ): Unit = { - ctx.run(query[persistence.Killactivity] + ctx.run(query[persistence.Assistactivity] .insert( _.killerId -> lift(avatarId), _.victimId -> lift(victimId), @@ -227,6 +227,12 @@ object ToDatabase { avatarIdAndExp.map { case (avatarId, exp, expType) => persistence.Buildingcapture(-1, avatarId, zoneId, buildingId, exp, expType) } - )}.foreach(e => query[persistence.Buildingcapture].insertValue(e))) + )}.foreach(e => query[persistence.Buildingcapture].insert( + _.avatarId -> e.avatarId, + _.zoneId -> e.zoneId, + _.buildingId -> e.buildingId, + _.exp -> e.exp, + _.expType -> e.expType + ))) } } diff --git a/src/main/scala/net/psforever/services/local/support/HackCaptureActor.scala b/src/main/scala/net/psforever/services/local/support/HackCaptureActor.scala index 3920dc8c..689d5e23 100644 --- a/src/main/scala/net/psforever/services/local/support/HackCaptureActor.scala +++ b/src/main/scala/net/psforever/services/local/support/HackCaptureActor.scala @@ -89,7 +89,10 @@ class HackCaptureActor extends Actor { val faction = GetHackingFaction(target).getOrElse(target.Faction) target.HackedBy = None hackedObjects = remainder + val now: Long = System.currentTimeMillis() + val facilityHackTime: Long = target.Definition.FacilityHackTime.toMillis val building = target.Owner.asInstanceOf[Building] + val hackTime = results.headOption.map { now - _.hack_timestamp }.getOrElse(facilityHackTime) // If LLU exists it was not delivered. Send resecured notifications building.GetFlag.collect { case flag: CaptureFlag => target.Zone.LocalEvents ! CaptureFlagManager.Lost(flag, CaptureFlagLostReasonEnum.Resecured) @@ -99,15 +102,15 @@ class HackCaptureActor extends Actor { target.Faction, faction, hacker, - target.Definition.FacilityHackTime.toMillis, - System.currentTimeMillis() - results.head.hack_timestamp, + facilityHackTime, + hackTime, isResecured = true ) // Restart the timer in case the object we just removed was the next one scheduled RestartTimer() case HackCaptureActor.FlagCaptured(flag) => - log.warn(hackedObjects.toString()) + log.debug(hackedObjects.toString()) val building = flag.Owner.asInstanceOf[Building] val bguid = building.CaptureTerminal.map { _.GUID } hackedObjects.find(entry => bguid.contains(entry.target.GUID)) match { @@ -160,13 +163,16 @@ class HackCaptureActor extends Actor { RestartTimer() spawnCaptureFlag(neighbours, terminal, hackingFaction) true - case Some((owner, Some(flag), _)) if hackingFaction == flag.Faction => + case Some((_, Some(flag), _)) if hackingFaction == flag.Faction => + log.error(s"TrySpawnCaptureFlag: flag hacked facility can not be hacked twice by $hackingFaction") + false + case Some((owner, _, _)) if hackingFaction == terminal.Faction => log.error(s"TrySpawnCaptureFlag: owning faction and hacking faction match for facility ${owner.Name}; should we be resecuring instead?") false - case Some((owner, _, _)) => - log.error(s"TrySpawnCaptureFlag: couldn't find any neighbouring $hackingFaction facilities of ${owner.Name} for LLU hack") + case Some((owner, Some(flag), _)) => + log.warn(s"TrySpawnCaptureFlag: couldn't find any neighbouring $hackingFaction facilities of ${owner.Name} for LLU hack") owner.GetFlagSocket.foreach { _.clearOldFlagData() } - terminal.Zone.LocalEvents ! CaptureFlagManager.Lost(owner.GetFlag.get, CaptureFlagLostReasonEnum.Ended) + terminal.Zone.LocalEvents ! CaptureFlagManager.Lost(flag, CaptureFlagLostReasonEnum.Ended) false case _ => log.error(s"TrySpawnCaptureFlag: expecting a terminal ${terminal.GUID.guid} with the ctf owning facility") diff --git a/src/main/scala/net/psforever/util/Config.scala b/src/main/scala/net/psforever/util/Config.scala index ccdf112e..149b59bc 100644 --- a/src/main/scala/net/psforever/util/Config.scala +++ b/src/main/scala/net/psforever/util/Config.scala @@ -244,14 +244,27 @@ case class Experience( longContributionTime: Long, bep: BattleExperiencePoints, sep: SupportExperiencePoints, - cep: CommandExperiencePoints + cep: CommandExperiencePoints, + facilityCaptureRate: Float ) { assert(shortContributionTime < longContributionTime) } +case class ThreatAssessment( + id: Int, + value: Float +) + +case class ThreatLevel( + id: Int, + level: Long +) + case class BattleExperiencePoints( - base: BattleExperiencePointsBase, - rate: Float + rate: Float, + base: BattleExperiencePointsBase, + lifeSpan: BattleExperiencePointsLifespan, + revenge: BattleExperiencePointsRevenge ) case class BattleExperiencePointsBase( @@ -259,7 +272,19 @@ case class BattleExperiencePointsBase( asMax: Long, withKills: Long, asMounted: Long, - mature: Long + mature: Long, + maturityTime: Long +) + +case class BattleExperiencePointsLifespan( + lifeSpanThreatRate: Float, + threatAssessmentOf: List[ThreatAssessment], + maxThreatLevel: List[ThreatLevel] +) + +case class BattleExperiencePointsRevenge( + rate: Float, + experience: Long ) case class SupportExperiencePoints( @@ -289,11 +314,11 @@ case class CommandExperiencePoints( ) case class PromotionSystem( - active: Boolean, - broadcastBattleRank: Int, - resetBattleRank: Int, - maxBattleRank: Int, - battleExperiencePointsModifier: Float, - supportExperiencePointsModifier: Float, - captureExperiencePointsModifier: Float + active: Boolean, + broadcastBattleRank: Int, + resetBattleRank: Int, + maxBattleRank: Int, + battleExperiencePointsModifier: Float, + supportExperiencePointsModifier: Float, + captureExperiencePointsModifier: Float )