diff --git a/src/main/scala/net/psforever/actors/session/AvatarActor.scala b/src/main/scala/net/psforever/actors/session/AvatarActor.scala
index 719a54c6..3bd05f35 100644
--- a/src/main/scala/net/psforever/actors/session/AvatarActor.scala
+++ b/src/main/scala/net/psforever/actors/session/AvatarActor.scala
@@ -5,6 +5,8 @@ import akka.actor.Cancellable
import akka.actor.typed.scaladsl.{ActorContext, Behaviors, StashBuffer}
import akka.actor.typed.{ActorRef, Behavior, PostStop, SupervisorStrategy}
import java.util.concurrent.atomic.AtomicInteger
+import net.psforever.actors.zone.ZoneActor
+import net.psforever.objects.avatar.scoring.{Death, EquipmentStat, KDAStat, Kill}
import org.joda.time.{LocalDateTime, Seconds}
import scala.collection.mutable
import scala.concurrent.{ExecutionContextExecutor, Future, Promise}
@@ -26,7 +28,6 @@ import net.psforever.objects.avatar.{
Shortcut => AvatarShortcut,
SpecialCarry
}
-import net.psforever.objects.avatar.scoring.{Death, EquipmentStat, KDAStat, Kill}
import net.psforever.objects.definition._
import net.psforever.objects.definition.converter.CharacterSelectConverter
import net.psforever.objects.equipment.{Equipment, EquipmentSlot}
@@ -192,6 +193,9 @@ object AvatarActor {
/** Award battle experience points */
final case class AwardBep(bep: Long, modifier: ExperienceType) extends Command
+ /** ... */
+ final case class UpdateKillsDeathsAssists(stat: KDAStat) extends Command
+
/** Set total battle experience points */
final case class SetBep(bep: Long) extends Command
@@ -1642,6 +1646,10 @@ class AvatarActor(
updateToolDischarge(stats)
Behaviors.same
+ case UpdateKillsDeathsAssists(stat) =>
+ updateKillsDeathsAssists(stat)
+ Behaviors.same
+
case AwardBep(bep, modifier) =>
setBep(avatar.bep + bep, modifier)
Behaviors.same
@@ -2936,18 +2944,17 @@ class AvatarActor(
val player = _session.player
kdaStat match {
case kill: Kill =>
- val _ = PlayerSource(player)
+ val playerSource = PlayerSource(player)
(kill.info.interaction.cause match {
case pr: ProjectileReason => pr.projectile.mounted_in.map { a => zone.GUID(a._1) }
- case _ => None
- }) match {
- case Some(Some(_: Vitality)) =>
- //zone.actor ! ZoneActor.RewardOurSupporters(playerSource, obj.History, kill, exp)
- case _ => ;
+ case _ => None
+ }).collect {
+ case Some(obj: Vitality) =>
+ zone.actor ! ZoneActor.RewardOurSupporters(playerSource, obj.History, kill, exp)
}
- //zone.actor ! ZoneActor.RewardOurSupporters(playerSource, player.History, kill, exp)
+ zone.actor ! ZoneActor.RewardOurSupporters(playerSource, player.History, kill, exp)
case _: Death =>
- player.Zone.AvatarEvents ! AvatarServiceMessage(
+ zone.AvatarEvents ! AvatarServiceMessage(
player.Name,
AvatarAction.SendResponse(
Service.defaultPlayerGUID,
diff --git a/src/main/scala/net/psforever/actors/session/support/SessionAvatarHandlers.scala b/src/main/scala/net/psforever/actors/session/support/SessionAvatarHandlers.scala
index 0f83c48b..dd08f21e 100644
--- a/src/main/scala/net/psforever/actors/session/support/SessionAvatarHandlers.scala
+++ b/src/main/scala/net/psforever/actors/session/support/SessionAvatarHandlers.scala
@@ -224,10 +224,9 @@ class SessionAvatarHandlers(
case AvatarResponse.DestroyDisplay(killer, victim, method, unk)
if killer.CharId == avatar.id && killer.Faction != victim.Faction =>
- // TODO Temporary thing that should go somewhere else and use proper xp values
sendResponse(sessionData.destroyDisplayMessage(killer, victim, method, unk))
- avatarActor ! AvatarActor.AwardBep((1000 * Config.app.game.bepRate).toLong, ExperienceType.Normal)
- avatarActor ! AvatarActor.AwardCep((100 * Config.app.game.cepRate).toLong)
+ //TODO Temporary thing that should go somewhere else and use proper xp values
+// avatarActor ! AvatarActor.AwardCep((100 * Config.app.game.cepRate).toLong)
case AvatarResponse.Destroy(victim, killer, weapon, pos) =>
// guid = victim // killer = killer
@@ -396,6 +395,9 @@ class SessionAvatarHandlers(
sessionData.kitToBeUsed = None
sendResponse(ChatMsg(ChatMessageType.UNK_225, msg))
+ case AvatarResponse.UpdateKillsDeathsAssists(_, kda) =>
+ avatarActor ! AvatarActor.UpdateKillsDeathsAssists(kda)
+
case AvatarResponse.SendResponse(msg) =>
sendResponse(msg)
diff --git a/src/main/scala/net/psforever/actors/zone/ZoneActor.scala b/src/main/scala/net/psforever/actors/zone/ZoneActor.scala
index 39f9756c..e355b1b7 100644
--- a/src/main/scala/net/psforever/actors/zone/ZoneActor.scala
+++ b/src/main/scala/net/psforever/actors/zone/ZoneActor.scala
@@ -7,12 +7,15 @@ import net.psforever.objects.equipment.Equipment
import net.psforever.objects.serverobject.structures.{StructureType, WarpGate}
import net.psforever.objects.zones.Zone
import net.psforever.objects.zones.blockmap.{BlockMapEntity, SectorGroup}
-import net.psforever.objects.{ConstructionItem, Player, Vehicle}
+import net.psforever.objects.{ConstructionItem, PlanetSideGameObject, Player, Vehicle}
import net.psforever.types.{PlanetSideEmpire, PlanetSideGUID, Vector3}
-
import akka.actor.typed.scaladsl.adapter._
import net.psforever.actors.zone.building.MajorFacilityLogic
+import net.psforever.objects.avatar.scoring.Kill
+import net.psforever.objects.serverobject.affinity.FactionAffinity
import net.psforever.objects.sourcing.SourceEntry
+import net.psforever.objects.vital.{InGameActivity, InGameHistory}
+import net.psforever.objects.zones.exp.ExperienceCalculator
import net.psforever.util.Database._
import net.psforever.persistence
@@ -70,6 +73,9 @@ object ZoneActor {
// Once they do, we won't need this anymore
final case class ZoneMapUpdate() extends Command
+ final case class RewardThisDeath(entity: PlanetSideGameObject with FactionAffinity with InGameHistory) extends Command
+
+ final case class RewardOurSupporters(target: SourceEntry, history: Iterable[InGameActivity], kill: Kill, bep: Long) extends Command
}
class ZoneActor(context: ActorContext[ZoneActor.Command], zone: Zone)
@@ -79,7 +85,8 @@ class ZoneActor(context: ActorContext[ZoneActor.Command], zone: Zone)
import ctx._
private[this] val log = org.log4s.getLogger
- val players: mutable.ListBuffer[Player] = mutable.ListBuffer()
+ private val players: mutable.ListBuffer[Player] = mutable.ListBuffer()
+ private val experience: ActorRef[ExperienceCalculator.Command] = context.spawnAnonymous(ExperienceCalculator(zone))
zone.actor = context.self
zone.init(context.toClassic)
@@ -143,6 +150,12 @@ class ZoneActor(context: ActorContext[ZoneActor.Command], zone: Zone)
case HotSpotActivity(defender, attacker, location) =>
zone.Activity ! Zone.HotSpot.Activity(defender, attacker, location)
+ case RewardThisDeath(entity) =>
+ experience ! ExperienceCalculator.RewardThisDeath(entity)
+
+ case RewardOurSupporters(target, history, kill, bep) =>
+ ()
+
case ZoneMapUpdate() =>
zone.Buildings
.filter(_._2.BuildingType == StructureType.Facility)
diff --git a/src/main/scala/net/psforever/objects/avatar/PlayerControl.scala b/src/main/scala/net/psforever/objects/avatar/PlayerControl.scala
index 3bc22033..bb2dfc6b 100644
--- a/src/main/scala/net/psforever/objects/avatar/PlayerControl.scala
+++ b/src/main/scala/net/psforever/objects/avatar/PlayerControl.scala
@@ -3,6 +3,7 @@ package net.psforever.objects.avatar
import akka.actor.{Actor, ActorRef, Props, typed}
import net.psforever.actors.session.AvatarActor
+import net.psforever.actors.zone.ZoneActor
import net.psforever.login.WorldSession.{DropEquipmentFromInventory, HoldNewEquipmentUp, PutNewEquipmentInInventoryOrDrop, RemoveOldEquipmentFromInventory}
import net.psforever.objects._
import net.psforever.objects.ce.Deployable
@@ -1052,6 +1053,7 @@ class PlayerControl(player: Player, avatarActor: typed.ActorRef[AvatarActor.Comm
case _ =>
events ! AvatarServiceMessage(zoneChannel, AvatarAction.DestroyDisplay(pentry, pentry, 0))
}
+ zone.actor ! ZoneActor.RewardThisDeath(player)
}
def suicide() : Unit = {
diff --git a/src/main/scala/net/psforever/objects/vital/InGameHistory.scala b/src/main/scala/net/psforever/objects/vital/InGameHistory.scala
index ae832624..2cc7f603 100644
--- a/src/main/scala/net/psforever/objects/vital/InGameHistory.scala
+++ b/src/main/scala/net/psforever/objects/vital/InGameHistory.scala
@@ -148,7 +148,7 @@ trait InGameHistory {
/**
* An in-game event must be recorded.
- * Add new entry to the front of the list (for recent activity).
+ * Add new entry to the list (for recent activity).
* @param action the fully-informed entry
* @return the list of previous changes to this entity
*/
@@ -157,14 +157,14 @@ trait InGameHistory {
/**
* An in-game event must be recorded.
- * Add new entry to the front of the list (for recent activity).
+ * Add new entry to the list (for recent activity).
* @param action the fully-informed entry
* @return the list of previous changes to this entity
*/
def LogActivity(action: Option[InGameActivity]): List[InGameActivity] = {
action match {
case Some(act) =>
- history = act +: history
+ history = history :+ act
case None => ()
}
history
diff --git a/src/main/scala/net/psforever/objects/zones/exp/ExperienceCalculator.scala b/src/main/scala/net/psforever/objects/zones/exp/ExperienceCalculator.scala
new file mode 100644
index 00000000..15f23698
--- /dev/null
+++ b/src/main/scala/net/psforever/objects/zones/exp/ExperienceCalculator.scala
@@ -0,0 +1,336 @@
+// Copyright (c) 2023 PSForever
+package net.psforever.objects.zones.exp
+
+import akka.actor.typed.scaladsl.{AbstractBehavior, ActorContext, Behaviors}
+import akka.actor.typed.{Behavior, SupervisorStrategy}
+import net.psforever.objects.PlanetSideGameObject
+import net.psforever.objects.avatar.scoring.{Assist, Death, Kill}
+import net.psforever.objects.serverobject.affinity.FactionAffinity
+import net.psforever.objects.sourcing.{PlayerSource, SourceEntry}
+import net.psforever.objects.vital.{DamagingActivity, HealingActivity, InGameActivity, InGameHistory, ReconstructionActivity, RepairFromExoSuitChange, RepairingActivity, SpawningActivity}
+import net.psforever.objects.vital.interaction.{Adversarial, DamageResult}
+import net.psforever.objects.zones.Zone
+import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage}
+import net.psforever.types.{ExoSuitType, PlanetSideEmpire}
+
+import scala.collection.mutable
+import scala.concurrent.duration._
+
+object ExperienceCalculator {
+ def apply(zone: Zone): Behavior[Command] =
+ Behaviors.supervise[Command] {
+ Behaviors.setup(context => new ExperienceCalculator(context, zone))
+ }.onFailure[Exception](SupervisorStrategy.restart)
+
+ sealed trait Command
+
+ final case class RewardThisDeath(victim: SourceEntry, lastDamage: Option[DamageResult], history: Iterable[InGameActivity])
+ extends ExperienceCalculator.Command
+
+ object RewardThisDeath {
+ def apply(obj: PlanetSideGameObject with FactionAffinity with InGameHistory): RewardThisDeath = {
+ RewardThisDeath(SourceEntry(obj), obj.LastDamage, obj.History)
+ }
+ }
+
+ def calculateExperience(
+ victim: PlayerSource,
+ history: Iterable[InGameActivity]
+ ): Long = {
+ val lifespan = (history.headOption, history.lastOption) match {
+ case (Some(spawn), Some(death)) => death.time - spawn.time
+ case _ => 0L
+ }
+ val wasEverAMax = victim.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
+ }
+ val base = if (wasEverAMax) { //shamed
+ 250L
+ } else if (victim.Seated || victim.kills.nonEmpty) {
+ 100L
+ } else if (lifespan > 15000L) {
+ 50L
+ } else {
+ 1L
+ }
+ if (base > 1) {
+ //black ops modifier
+ //TODO x10
+ base
+ } else {
+ base
+ }
+ }
+}
+
+class ExperienceCalculator(context: ActorContext[ExperienceCalculator.Command], zone: Zone)
+ extends AbstractBehavior[ExperienceCalculator.Command](context) {
+
+ import ExperienceCalculator._
+
+ def onMessage(msg: Command): Behavior[Command] = {
+ msg match {
+ case RewardThisDeath(victim: PlayerSource, lastDamage, history) =>
+ rewardThisPlayerDeath(
+ victim,
+ lastDamage,
+ limitHistoryToThisLife(history.toList)
+ )
+ case _ =>
+ ()
+ }
+ Behaviors.same
+ }
+
+ def rewardThisPlayerDeath(
+ victim: PlayerSource,
+ lastDamage: Option[DamageResult],
+ history: List[InGameActivity]
+ ): Unit = {
+ val everyone = determineKiller(lastDamage, history) match {
+ case Some((result, killer: PlayerSource)) =>
+ val assists = collectAssistsForPlayer(victim, history, Some(killer))
+ val fullBep = KillDeathAssists.calculateExperience(killer, victim, history)
+ val hitSquad = (killer, Kill(victim, result, fullBep)) +: assists.map {
+ case ContributionStatsOutput(p, w, r) => (p, Assist(victim, w, r, (fullBep * r).toLong))
+ }.toSeq
+ (victim, Death(hitSquad.map { _._1 }, history.last.time - history.head.time, fullBep)) +: hitSquad
+
+ case _ =>
+ val assists = collectAssistsForPlayer(victim, history, None)
+ val fullBep = ExperienceCalculator.calculateExperience(victim, history)
+ val hitSquad = assists.map {
+ case ContributionStatsOutput(p, w, r) => (p, Assist(victim, w, r, (fullBep * r).toLong))
+ }.toSeq
+ (victim, Death(hitSquad.map { _._1 }, history.last.time - history.head.time, fullBep)) +: hitSquad
+ }
+ val events = zone.AvatarEvents
+ everyone.foreach { case (p, kda) =>
+ events ! AvatarServiceMessage(p.Name, AvatarAction.UpdateKillsDeathsAssists(p.CharId, kda))
+ }
+ }
+
+ def limitHistoryToThisLife(history: List[InGameActivity]): List[InGameActivity] = {
+ val spawnIndex = history.indexWhere {
+ case SpawningActivity(_, _, _) => true
+ case _ => false
+ }
+ val endIndex = history.lastIndexWhere {
+ case damage: DamagingActivity => damage.data.targetAfter.asInstanceOf[PlayerSource].Health == 0
+ case _ => false
+ }
+ if (spawnIndex == -1 || endIndex == -1) {
+ Nil //throw VitalsHistoryException(history.head, "vitals history does not contain expected conditions")
+// } else
+// if (spawnIndex == -1) {
+// Nil //throw VitalsHistoryException(history.head, "vitals history does not contain initial spawn conditions")
+// } else if (endIndex == -1) {
+// Nil //throw VitalsHistoryException(history.last, "vitals history does not contain end of life conditions")
+ } else {
+ history.slice(spawnIndex, endIndex)
+ }
+ }
+
+ def determineKiller(lastDamageActivity: Option[DamageResult], history: List[InGameActivity]): Option[(DamageResult, SourceEntry)] = {
+ val now = System.currentTimeMillis()
+ val compareTimeMillis = 10.seconds.toMillis
+ lastDamageActivity
+ .collect { case dam if now - dam.interaction.hitTime < compareTimeMillis => dam }
+ .flatMap { dam => Some(dam, dam.adversarial) }
+ .orElse {
+ history.collect { case damage: DamagingActivity
+ if now - damage.time < compareTimeMillis && damage.data.adversarial.nonEmpty =>
+ damage.data
+ }
+ .flatMap { dam => Some(dam, dam.adversarial) }.lastOption
+ }
+ .collect { case (dam, Some(adv)) => (dam, adv.attacker) }
+ }
+
+ private[exp] def collectAssistsForPlayer(
+ victim: PlayerSource,
+ history: List[InGameActivity],
+ killerOpt: Option[PlayerSource]
+ ): Iterable[ContributionStatsOutput] = {
+ // val cardinalSin = victim.ExoSuit == ExoSuitType.MAX || history.exists {
+ // case SpawningActivity(p: PlayerSource,_,_) => p.ExoSuit == ExoSuitType.MAX
+ // case RepairFromExoSuitChange(suit, _) => suit == ExoSuitType.MAX
+ // case _ => false
+ // }
+ val initialHealth = history
+ .headOption
+ .collect { case SpawningActivity(p: PlayerSource,_,_) => p.health } match {
+ case Some(value) => value.toFloat
+ case _ => 100f
+ }
+ val healthAssists = collectHealthAssists(
+ victim,
+ history,
+ initialHealth,
+ allocateContributors(healthDamageContributors)
+ )
+ healthAssists.remove(0L)
+ killerOpt.map { killer => healthAssists.remove(killer.CharId) }
+ healthAssists.values
+ }
+
+ private def allocateContributors(
+ tallyFunc: (List[InGameActivity], PlanetSideEmpire.Value, mutable.LongMap[ContributionStats]) => Any
+ )
+ (
+ history: List[InGameActivity],
+ faction: PlanetSideEmpire.Value
+ ): mutable.LongMap[ContributionStats] = {
+ /** players who have contributed to this death, and how much they have contributed
+ * key - character identifier,
+ * value - (player, damage, total damage, number of shots) */
+ val participants: mutable.LongMap[ContributionStats] = mutable.LongMap[ContributionStats]()
+ tallyFunc(history, faction, participants)
+ participants
+ }
+
+
+
+ private def healthDamageContributors(
+ history: List[InGameActivity],
+ faction: PlanetSideEmpire.Value,
+ participants: mutable.LongMap[ContributionStats]
+ ): Seq[(Long, Int)] = {
+ /** damage as it is measured in order (with heal-countered damage eliminated)
+ * key - character identifier,
+ * value - current damage contribution */
+ var inOrder: Seq[(Long, Int)] = Seq[(Long, Int)]()
+ history.tail.foreach {
+ case d: DamagingActivity if d.health > 0 =>
+ inOrder = contributeWithDamagingActivity(d, faction, d.health, participants, inOrder)
+ case _: RepairingActivity => ()
+ case h: HealingActivity =>
+ inOrder = contributeWithRecoveryActivity(h.amount, participants, inOrder)
+ case _ => ()
+ }
+ inOrder
+ }
+
+ private def collectHealthAssists(
+ victim: SourceEntry,
+ history: List[InGameActivity],
+ topHealth: Float,
+ func: (List[InGameActivity], PlanetSideEmpire.Value)=>mutable.LongMap[ContributionStats]
+ ): mutable.LongMap[ContributionStatsOutput] = {
+ val healthAssists = func(history, victim.Faction)
+ .filterNot { case (_, kda) => kda.amount <= 0 }
+ .map { case (id, kda) =>
+ (id, ContributionStatsOutput(kda.player, kda.weapons.map { _.weapon_id }, kda.amount / topHealth))
+ }
+ healthAssists.remove(victim.CharId)
+ healthAssists
+ }
+
+ private def contributeWithDamagingActivity(
+ activity: DamagingActivity,
+ faction: PlanetSideEmpire.Value,
+ amount: Int,
+ participants: mutable.LongMap[ContributionStats],
+ order: Seq[(Long, Int)]
+ ): Seq[(Long, Int)] = {
+ activity.data.adversarial match {
+ case Some(Adversarial(attacker: PlayerSource, _, _))
+ if attacker.Faction != faction =>
+ val whoId = attacker.CharId
+ val wepid = activity.data.interaction.cause.attribution
+ val time = activity.time
+ val updatedEntry = participants.get(whoId) match {
+ case Some(mod) =>
+ //previous attacker, just add to entry
+ val firstWeapon = mod.weapons.head
+ val weapons = if (firstWeapon.weapon_id == wepid) {
+ firstWeapon.copy(amount = firstWeapon.amount + amount, shots = firstWeapon.shots + 1, time = time) +: mod.weapons.tail
+ } else {
+ WeaponStats(wepid, amount, 1, time) +: mod.weapons
+ }
+ mod.copy(
+ amount = mod.amount + amount,
+ weapons = weapons,
+ totalDamage = mod.totalDamage + amount,
+ shots = mod.shots + 1,
+ time = activity.time
+ )
+ case None =>
+ //new attacker, new entry
+ ContributionStats(
+ attacker,
+ Seq(WeaponStats(wepid, amount, 1, time)),
+ amount,
+ amount,
+ 1,
+ time
+ )
+ }
+ participants.put(whoId, updatedEntry)
+ order.indexWhere({ case (id, _) => id == whoId }) match {
+ case 0 =>
+ //ongoing attack by same player
+ val entry = order.head
+ (entry._1, entry._2 + amount) +: order.tail
+ case _ =>
+ //different player than immediate prior attacker
+ (whoId, amount) +: order
+ }
+ case _ =>
+ //damage that does not lead to contribution
+ order.headOption match {
+ case Some((id, dam)) =>
+ if (id == 0L) {
+ (0L, dam + amount) +: order.tail //pool
+ } else {
+ (0L, amount) +: order //new
+ }
+ case None =>
+ order
+ }
+ }
+ }
+
+ private def contributeWithRecoveryActivity(
+ amount: Int,
+ participants: mutable.LongMap[ContributionStats],
+ order: Seq[(Long, Int)]
+ ): Seq[(Long, Int)] = {
+ var amt = amount
+ var count = 0
+ var newOrder: 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
+ }
+ pweapons(index).copy(amount = weaponSum - amt) +: pweapons.slice(index+1, pweapons.size)
+ }
+ newOrder = (id, newTotal) +: newOrder
+ participants.put(id, part.copy(amount = part.amount - amount, weapons = trimmedWeapons))
+ amt = 0
+ }
+ }
+ count += 1
+ amt > 0
+ }
+ newOrder ++ order.drop(count)
+ }
+}
diff --git a/src/main/scala/net/psforever/objects/zones/exp/KillDeathAssists.scala b/src/main/scala/net/psforever/objects/zones/exp/KillDeathAssists.scala
new file mode 100644
index 00000000..4560b7a8
--- /dev/null
+++ b/src/main/scala/net/psforever/objects/zones/exp/KillDeathAssists.scala
@@ -0,0 +1,40 @@
+// Copyright (c) 2023 PSForever
+package net.psforever.objects.zones.exp
+
+import net.psforever.objects.sourcing.PlayerSource
+import net.psforever.objects.vital.InGameActivity
+
+object KillDeathAssists {
+ private[exp] def calculateExperience(
+ killer: PlayerSource,
+ victim: PlayerSource,
+ history: Iterable[InGameActivity]
+ ): Long = {
+ //base value (the kill experience before modifiers)
+ val base = ExperienceCalculator.calculateExperience(victim, history)
+ if (base > 1) {
+ //battle rank disparity modifiers
+ val battleRankDisparity = {
+ import net.psforever.objects.avatar.BattleRank
+ val killerLevel = BattleRank.withExperience(killer.bep).value
+ val victimLevel = BattleRank.withExperience(victim.bep).value
+ if (victimLevel > killerLevel || killerLevel - victimLevel < 6) {
+ if (killerLevel < 7) {
+ 6 * victimLevel + 10
+ } else if (killerLevel < 12) {
+ (12 - killerLevel) * victimLevel + 10
+ } else if (killerLevel < 25) {
+ 25 + victimLevel - killerLevel
+ } else {
+ 25
+ }
+ } else {
+ math.floor(-0.15f * base - killerLevel + victimLevel).toLong
+ }
+ }
+ math.max(1, base + battleRankDisparity)
+ } else {
+ base
+ }
+ }
+}
diff --git a/src/main/scala/net/psforever/objects/zones/exp/Stats.scala b/src/main/scala/net/psforever/objects/zones/exp/Stats.scala
new file mode 100644
index 00000000..a27b6e4a
--- /dev/null
+++ b/src/main/scala/net/psforever/objects/zones/exp/Stats.scala
@@ -0,0 +1,26 @@
+// Copyright (c) 2023 PSForever
+package net.psforever.objects.zones.exp
+
+import net.psforever.objects.sourcing.PlayerSource
+
+private case class WeaponStats(
+ weapon_id: Int,
+ amount: Int,
+ shots: Int,
+ time: Long
+ )
+
+private case class ContributionStats(
+ player: PlayerSource,
+ weapons: Seq[WeaponStats],
+ amount: Int,
+ totalDamage: Int,
+ shots: Int,
+ time: Long
+ )
+
+sealed case class ContributionStatsOutput(
+ player: PlayerSource,
+ implements: Seq[Int],
+ percentage: Float
+ )
diff --git a/src/main/scala/net/psforever/objects/zones/exp/VitalsHistoryException.scala b/src/main/scala/net/psforever/objects/zones/exp/VitalsHistoryException.scala
new file mode 100644
index 00000000..e5fd7e67
--- /dev/null
+++ b/src/main/scala/net/psforever/objects/zones/exp/VitalsHistoryException.scala
@@ -0,0 +1,10 @@
+// Copyright (c) 2023 PSForever
+package net.psforever.objects.zones.exp
+
+import net.psforever.objects.vital.InGameActivity
+
+final case class VitalsHistoryException(
+ head: InGameActivity, //InGameActivity might be more suitable?
+ private val message: String = "",
+ private val cause: Throwable = None.orNull
+ ) extends Exception(message, cause)
diff --git a/src/main/scala/net/psforever/services/avatar/AvatarService.scala b/src/main/scala/net/psforever/services/avatar/AvatarService.scala
index 6fb9d8cb..c560723a 100644
--- a/src/main/scala/net/psforever/services/avatar/AvatarService.scala
+++ b/src/main/scala/net/psforever/services/avatar/AvatarService.scala
@@ -429,6 +429,15 @@ class AvatarService(zone: Zone) extends Actor {
)
)
+ case AvatarAction.UpdateKillsDeathsAssists(charId, stat) =>
+ AvatarEvents.publish(
+ AvatarServiceResponse(
+ s"/$forChannel/Avatar",
+ Service.defaultPlayerGUID,
+ AvatarResponse.UpdateKillsDeathsAssists(charId, stat)
+ )
+ )
+
case _ => ;
}
diff --git a/src/main/scala/net/psforever/services/avatar/AvatarServiceMessage.scala b/src/main/scala/net/psforever/services/avatar/AvatarServiceMessage.scala
index feaef692..70574ef7 100644
--- a/src/main/scala/net/psforever/services/avatar/AvatarServiceMessage.scala
+++ b/src/main/scala/net/psforever/services/avatar/AvatarServiceMessage.scala
@@ -2,6 +2,7 @@
package net.psforever.services.avatar
import net.psforever.objects.Player
+import net.psforever.objects.avatar.scoring.KDAStat
import net.psforever.objects.ballistics.Projectile
import net.psforever.objects.ce.Deployable
import net.psforever.objects.equipment.Equipment
@@ -154,6 +155,8 @@ object AvatarAction {
final case class UseKit(kit_guid: PlanetSideGUID, kit_objid: Int) extends Action
final case class KitNotUsed(kit_guid: PlanetSideGUID, msg: String) extends Action
+ final case class UpdateKillsDeathsAssists(charId: Long, kda: KDAStat) extends Action
+
final case class TeardownConnection() extends Action
// final case class PlayerStateShift(killer : PlanetSideGUID, victim : PlanetSideGUID) extends Action
// final case class DestroyDisplay(killer : PlanetSideGUID, victim : PlanetSideGUID) extends Action
diff --git a/src/main/scala/net/psforever/services/avatar/AvatarServiceResponse.scala b/src/main/scala/net/psforever/services/avatar/AvatarServiceResponse.scala
index 4064cec4..e3a98ee9 100644
--- a/src/main/scala/net/psforever/services/avatar/AvatarServiceResponse.scala
+++ b/src/main/scala/net/psforever/services/avatar/AvatarServiceResponse.scala
@@ -2,6 +2,7 @@
package net.psforever.services.avatar
import net.psforever.objects.Player
+import net.psforever.objects.avatar.scoring.KDAStat
import net.psforever.objects.ballistics.Projectile
import net.psforever.objects.equipment.Equipment
import net.psforever.objects.inventory.InventoryItem
@@ -124,4 +125,6 @@ object AvatarResponse {
// final case class PlayerStateShift(itemID : PlanetSideGUID) extends Response
final case class UseKit(kit_guid: PlanetSideGUID, kit_objid: Int) extends Response
final case class KitNotUsed(kit_guid: PlanetSideGUID, msg: String) extends Response
+
+ final case class UpdateKillsDeathsAssists(charId: Long, kda: KDAStat) extends Response
}