From 5b0ffdb5aa29880c652510ec4c29e74504296697 Mon Sep 17 00:00:00 2001 From: Fate-JH Date: Thu, 28 Dec 2023 01:20:16 -0500 Subject: [PATCH] augmented the calculations for bep --- src/main/resources/application.conf | 99 +++- .../objects/avatar/PlayerControl.scala | 2 +- .../MajorFacilityHackParticipation.scala | 4 +- .../TowerHackParticipation.scala | 4 +- .../objects/vital/InGameHistory.scala | 21 +- .../objects/zones/exp/KillAssists.scala | 211 ++------- .../objects/zones/exp/KillContributions.scala | 4 +- .../psforever/objects/zones/exp/Support.scala | 443 +++++++++++++++++- .../scala/net/psforever/util/Config.scala | 47 +- 9 files changed, 612 insertions(+), 223 deletions(-) diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index c0fa142bd..defc1f2db 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.0 + }, + { + id = 1 + value = 1.25 + }, + { + id = 2 + value = 2.0 + }, + { + id = 3 + value = 1.0 + }, + { + id = 4 + value = 0.75 + }, + { + id = 258 + value = 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 = 900 + }, + { + id = 1 + level = 1200 + }, + { + id = 2 + level = 5000 + }, + { + id = 3 + level = 1000 + }, + { + id = 4 + level = 20000 + }, + { + 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 bc6e1d019..28322e14c 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/serverobject/structures/participation/MajorFacilityHackParticipation.scala b/src/main/scala/net/psforever/objects/serverobject/structures/participation/MajorFacilityHackParticipation.scala index 1cfaa2c76..05a01682f 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 4f5916804..b780c7490 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 2e96aeb6c..d62990974 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 92dbf756c..46390d202 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._ @@ -150,154 +150,6 @@ object KillAssists { } } - /** - * "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 @@ -306,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 + } } } 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 93293aaa3..410492f90 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 a4fd65a77..11f016fae 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,444 @@ 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) + 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 lastSuitAct: Long = history.head.time + var lastDismountAct: Option[VehicleMountChange] = None + var lastMountAct: Option[VehicleMountChange] = None + //collect history events that encompass changes to exo-suits and to mounting conditions + history.collect { + case suitChange: ExoSuitChange => + //use previous vehicle dismount to distinguish between infantry exo-suit use and pilot exo-suit use + val timePassed = lastDismountAct.map(_.time).getOrElse(lastSuitAct) - suitChange.time + wornTime.get(currentSuit) match { + case None => wornTime.update(currentSuit, timePassed) + case Some(oldTime) => wornTime.update(currentSuit, oldTime + timePassed) + } + currentSuit = suitChange.exosuit.id + lastSuitAct = suitChange.time + lastDismountAct = None + case mount: VehicleMountActivity => + wornTime.getOrElseUpdate(mount.vehicle.Definition.ObjectId, 0L) + lastDismountAct = None + lastMountAct = Some(mount) + case dismount: VehicleDismountActivity + if dismount.pairedEvent.isEmpty => + //though we have reference to a previous mount activity, only care about the dismount activity's knowledge + wornTime.getOrElseUpdate(dismount.vehicle.Definition.ObjectId, 0L) + lastDismountAct = Some(dismount) + lastMountAct = None + case dismount: VehicleDismountActivity => + val timePassed = dismount.time - dismount.pairedEvent.get.time + val input = dismount.vehicle.Definition.ObjectId + wornTime.get(input) match { + case None => wornTime.update(input, timePassed) + case Some(oldTime) => wornTime.update(input, oldTime + timePassed) + } + lastDismountAct = Some(dismount) + 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 + val input = mount.vehicle.Definition.ObjectId + val lastMountTime = lastTime - mount.time + wornTime.get(input) match { + case None => wornTime.update(input, lastMountTime) + case Some(oldTime) => wornTime.update(input, oldTime + lastMountTime) + } + Some(mount) + } + .orElse { + //dying while on foot + val lastSuitTime = lastTime - lastDismountAct.map(_.time).getOrElse(lastSuitAct) + wornTime.get(currentSuit) match { + case None => wornTime.update(currentSuit, lastSuitTime) + case Some(oldTime) => wornTime.update(currentSuit, oldTime + lastSuitTime) + } + None + } + wornTime.toMap + } + + /** + * 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] + ): Long = { + val bep = Config.app.game.experience.bep + //awarded value for a target's lifespan based on the distribution of their tactical choices + val threatEstimate = (recordOfWornTimes.foldLeft(0L) { + case (sum, (key, amount)) => + if (key > 10) sum + amount + else sum + (amount * bep.lifeSpan.threatAssessmentOf.find { case ThreatAssessment(a, _) => a == key }.map(_.value).getOrElse(1.0f)).toLong + } * bep.lifeSpan.lifeSpanThreatRate).toLong + //maximum award for a target's lifespan based on the greatest potential of their tactical choices + val maxThreatLevel : Long = estimateMaxThreatLevel( + recordOfWornTimes, + recordOfWornTimes.maxBy { case (_, value) => value }._1 + ) + //menace modifier -> min = kills, max = 8 x kills + val menace = (player.progress.kills.size.toFloat * (1f + Support.calculateMenace(player).toFloat)).toLong + //cap + math.min(threatEstimate + menace, maxThreatLevel) + } + + /** + * Calculate the maximum possible reward available based on tactical options. + * @param recordOfWornTimes between equipment (object class ids) and the time that equipment has been used (ms) + * @param testKey equipment id used to estimate one sample for the ceiling value + * @param defaultThreatLevel what to use for an unresolved ceiling value; + * defaults to 0 + * @return maximum value of the kill in what the game called "battle experience points" + */ + private def estimateMaxThreatLevel( + recordOfWornTimes: Map[Int, Long], + testKey: Int, + defaultThreatLevel: Long = 0L + ): Long = { + val exoSuitMaxThreatId = recordOfWornTimes + .filter { case (key, _) => key < 10 } + .maxBy { case (_, value) => value } + ._1 + val estimatedExosuitThreatLevel = estimateMaxThreatLevelBasedOnKey(exoSuitMaxThreatId, defaultThreatLevel) + math.max( + estimateMaxThreatLevelBasedOnKey(testKey, estimatedExosuitThreatLevel), + estimatedExosuitThreatLevel + ) + } + + /** + * Calculate the maximum possible reward available based on a tactical option by id. + * @param refId equipment id used to collect the ceiling value + * @param defaultThreatLevel what to use for an unresolved ceiling value + * @return maximum value of the kill in what the game called "battle experience points" + * @see `Config.app.game.experience.bep.maxThreatLevel` + * @see `DefinitionUtil.idToDefinition` + * @see `VitalityDefinition.MaxHealth` + */ + private def estimateMaxThreatLevelBasedOnKey( + refId: Int, + defaultThreatLevel: Long + ): Long = { + Config.app.game.experience.bep.lifeSpan.maxThreatLevel + .find { case ThreatLevel(key, _) => key == refId } + .map(_.level) + .getOrElse( + try { + DefinitionUtil.idToDefinition(refId) match { + case o: VitalityDefinition => (o.MaxHealth * 1.5f).toLong + case _ => defaultThreatLevel + } + } catch { + case _: Exception => defaultThreatLevel + } + ) + } + + /** + * "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 +580,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 +604,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 +621,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/util/Config.scala b/src/main/scala/net/psforever/util/Config.scala index ccdf112ef..149b59bc3 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 )