diff --git a/server/src/main/resources/db/migration/V011__ScoringPatch2.sql b/server/src/main/resources/db/migration/V011__ScoringPatch2.sql new file mode 100644 index 000000000..7a303720f --- /dev/null +++ b/server/src/main/resources/db/migration/V011__ScoringPatch2.sql @@ -0,0 +1,22 @@ +/* Original: V008__Scoring.sql */ +CREATE OR REPLACE FUNCTION fn_assistactivity_updateRelatedStats() +RETURNS TRIGGER +AS +$$ +DECLARE killerSessionId Int; +DECLARE killerId Int; +DECLARE weaponId Int; +DECLARE out integer; +BEGIN + killerId := NEW.killer_id; + weaponId := NEW.weapon_id; + killerSessionId := proc_sessionnumber_get(killerId); + out := proc_weaponstatsession_addEntryIfNoneWithSessionId(killerId, killerSessionId, weaponId); + BEGIN + UPDATE weaponstatsession + SET assists = assists + 1 + WHERE avatar_id = killerId AND session_id = killerSessionId AND weapon_id = weaponId; + END; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; \ No newline at end of file diff --git a/server/src/test/scala/actor/objects/VehicleSpawnPadTest.scala b/server/src/test/scala/actor/objects/VehicleSpawnPadTest.scala index 86423a0e5..6ca49eca0 100644 --- a/server/src/test/scala/actor/objects/VehicleSpawnPadTest.scala +++ b/server/src/test/scala/actor/objects/VehicleSpawnPadTest.scala @@ -231,17 +231,17 @@ object VehicleSpawnPadControlTest { override def SetupNumberPools(): Unit = {} } zone.GUID(guid) - zone.actor = system.spawn(ZoneActor(zone), s"test-zone-${System.nanoTime()}") + zone.actor = system.spawn(ZoneActor(zone), s"test-zone-${System.currentTimeMillis()}") // Hack: Wait for the Zone to finish booting, otherwise later tests will fail randomly due to race conditions // with actor probe setting // TODO(chord): Remove when Zone supports notification of booting being complete Thread.sleep(5000) - vehicle.Actor = system.actorOf(Props(classOf[VehicleControl], vehicle), s"vehicle-control-${System.nanoTime()}") + vehicle.Actor = system.actorOf(Props(classOf[VehicleControl], vehicle), s"vehicle-control-${System.currentTimeMillis()}") val pad = VehicleSpawnPad(GlobalDefinitions.mb_pad_creation) - pad.Actor = system.actorOf(Props(classOf[VehicleSpawnControl], pad), s"test-pad-${System.nanoTime()}") + pad.Actor = system.actorOf(Props(classOf[VehicleSpawnControl], pad), s"test-pad-${System.currentTimeMillis()}") pad.Owner = new Building("Building", building_guid = 0, map_id = 0, zone, StructureType.Building, GlobalDefinitions.building) pad.Owner.Faction = faction diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index 2396ea92c..de91cf966 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -75,12 +75,6 @@ game { third-party = no } - # Battle experience rate - bep-rate = 1.0 - - # Command experience rate - cep-rate = 1.0 - # Modify the amount of mending per autorepair tick for facility amenities amenity-autorepair-rate = 1.0 @@ -223,6 +217,190 @@ game { # Don't ask. doors-can-be-opened-by-med-app-from-this-distance = 5.05 + + # How the experience calculates + experience { + # The short contribution time when events are collected and evaluated. + short-contribution-time = 300000 + # The long contribution time when events are collected and evaluated + # even factoring the same events from the short contribution time. + # As a result, when comparing the two event lists, similar actors may appear + # but their contributions may be different. + long-contribution-time = 600000 + + # 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 + rate = 1.0 + # These numbers are to determine the starting value for a particular kill + base = { + # Black Ops multiplies the base value by this much + bops-multiplier = 10.0 + # 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 + # 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 + } + } + + # Support experience points + # 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 + 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, + # reward the user with this amount of support experience points. + # Small deposits reward only a percentage of this value. + ntu-silo-deposit-reward = 100 + # When the event can not be found, this flat sum is rewarded. + # This should not be treated as a feature. + # It is a bug. + # Check your event label calls. + can-not-find-event-default-value = 15 + # The events by which support experience calculation occurs. + # Events can be composed of three parts: a base value, a per-use (shots) value, and an active amount value. + # "Per-use" relies on knowledge from the server about the number of times this exact action occurred before the event. + # "Active amount" relies on knowledge from the server about how much of the changes for this event are still valid. + # Some changes can be undone by other events or other behavior. + # + # name - label by which this event is organized + # base - whole number value + # shots-multiplier - whether use count matters for this event + # - when set to 0.0 (default), it does not + # shots-limit - upper limit of use count + # - cap the count here, if higher + # shots-cutoff - if the use count exceeds this number, the event no longer applies + # - a hard limit that should zero the contribution reward + # - the *-cutoff should probably apply before *-limit, maybe + # shots-nat-log - when set, may the use count to a natural logarithmic curve + # - actually the exponent on the use count before the logarithm + # - similar to shots-limit, but the curve plateaus quickly + # amount-multiplier - whether active amount matters for this event + # - when set to 0.0 (default), it does not + events = [ + { + name = "support-heal" + base = 10 + shots-multiplier = 5.0 + shots-limit = 100 + amount-multiplier = 2.0 + } + { + name = "support-repair" + base = 10 + shots-multiplier = 5.0 + shots-limit = 100 + } + { + name = "support-repair-terminal" + base = 10 + shots-multiplier = 5.0 + shots-limit = 100 + } + { + name = "support-repair-turret" + base = 10 + shots-multiplier = 5.0 + shots-limit = 100 + } + { + name = "mounted-kill" + base = 25 + } + { + name = "router" + base = 15 + } + { + name = "hotdrop" + base = 25 + } + { + name = "hack" + base = 5 + amount-multiplier = 5.0 + } + { + name = "ams-resupply" + base = 15 + shots-multiplier = 1.0 + shots-nat-log = 5.0 + } + { + name = "lodestar-repair" + base = 10 + shots-multiplier = 1.0 + shots-nat-log = 5.0 + shots-limit = 100 + amount-multiplier = 1.0 + } + { + name = "lodestar-rearm" + base = 10 + shots-multiplier = 1.0 + shots-nat-log = 5.0 + } + { + name = "revival" + base = 0 + shots-multiplier = 15.0 + shots-nat-log = 5.0 + shots-cutoff = 10 + } + ] + } + + # Support experience points + cep = { + # After all calculations are complete, multiple the result by this value + rate = 1.0 + # When command experience points are rewarded to the lattice link unit carrier, + # modify the original value by this modifier. + llu-carrier-modifier = 0.5 + # If a player died while carrying an lattice logic unit, + # award the player who is accredited with the kill command experience as long as the time it had been carried longer than this duration. + # Can set to Duration.Inf to never pass. + llu-slayer-credit-duration = 1 minute + # If a player died while carrying an lattice logic unit, + # and satisfies the carrying duration, + # award the player who is accredited with the kill command experience. + llu-slayer-credit = 200 + # The maximum command experience that can be earned in a facility capture based on squad size + maximum-per-squad-size = [990, 1980, 3466, 4950, 6436, 7920, 9406, 10890, 12376, 13860] + # When the cep has to be capped for squad size, add a small value to the capped value + # -1 reuses the cep before being capped + squad-size-limit-overflow = -1 + # 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 + } + } + + # The game's official maximum battle rank is 40. + # This is an artificial cap that attempts to stop advancement long before that. + # After becoming this battle rank, battle experience points gain will be locked. + # In our case, we're imposing this because character features can be unstable when above BR24. + max-battle-rank = 24 + + promotion { + # Whether promotion versus play is offered at battle rank 1. + # Anyone who is currently enrolled in the promotion system remains enrolled during normal game play. + # Relenting on the promotion debt back to battle rank 2 is still possible. + active = true + # This is the maximum battle rank that can be set as part of the promotion system. + max-battle-rank = 13 + # How much direct combat contributes to paying back promotion debt. + # Typically, it does not contribute. + battle-experience-points-modifier = 0f + # Don't forget to pay back that debt. + } } anti-cheat { diff --git a/src/main/scala/net/psforever/actors/net/LoginActor.scala b/src/main/scala/net/psforever/actors/net/LoginActor.scala index 856399e65..3fe6cd46e 100644 --- a/src/main/scala/net/psforever/actors/net/LoginActor.scala +++ b/src/main/scala/net/psforever/actors/net/LoginActor.scala @@ -137,12 +137,10 @@ class LoginActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], conne bcryptedPassword } - def getAccountLogin(username: String, password: Option[String], token: Option[String]): Unit = { - - if (token.isDefined) { - accountLoginWithToken(token.getOrElse("")) - } else { - accountLogin(username, password.getOrElse("")) + def getAccountLogin(username: String, passwordOpt: Option[String], tokenOpt: Option[String]): Unit = { + tokenOpt match { + case Some(token) => accountLoginWithToken(token) + case None => accountLogin(username, passwordOpt.getOrElse("")) } } @@ -164,7 +162,8 @@ class LoginActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], conne accountOption <- accountsExact.headOption orElse accountsLower.headOption match { // account found - case Some(account) => Future.successful(Some(account)) + case Some(account) => + Future.successful(Some(account)) // create new account case None => diff --git a/src/main/scala/net/psforever/actors/session/AvatarActor.scala b/src/main/scala/net/psforever/actors/session/AvatarActor.scala index 3bd05f353..4d4702951 100644 --- a/src/main/scala/net/psforever/actors/session/AvatarActor.scala +++ b/src/main/scala/net/psforever/actors/session/AvatarActor.scala @@ -4,10 +4,17 @@ package net.psforever.actors.session 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 net.psforever.objects.avatar.scoring.{Assist, Death, EquipmentStat, KDAStat, Kill, Life, ScoreCard, SupportActivity} +import net.psforever.objects.serverobject.affinity.FactionAffinity +import net.psforever.objects.sourcing.VehicleSource +import net.psforever.objects.vital.InGameHistory +import net.psforever.objects.vehicles.MountedWeapons +import net.psforever.types.{ChatMessageType, StatisticalCategory, StatisticalElement} import org.joda.time.{LocalDateTime, Seconds} + import scala.collection.mutable import scala.concurrent.{ExecutionContextExecutor, Future, Promise} import scala.util.{Failure, Success} @@ -36,7 +43,7 @@ import net.psforever.objects.loadouts.{InfantryLoadout, Loadout, VehicleLoadout} import net.psforever.objects.locker.LockerContainer import net.psforever.objects.sourcing.{PlayerSource,SourceWithHealthEntry} import net.psforever.objects.vital.projectile.ProjectileReason -import net.psforever.objects.vital.{DamagingActivity, HealFromImplant, HealingActivity, SpawningActivity, Vitality} +import net.psforever.objects.vital.{DamagingActivity, HealFromImplant, HealingActivity, SpawningActivity} import net.psforever.packet.game.objectcreate.{BasicCharacterData, ObjectClass, RibbonBars} import net.psforever.packet.game.{Friend => GameFriend, _} import net.psforever.persistence @@ -199,11 +206,17 @@ object AvatarActor { /** Set total battle experience points */ final case class SetBep(bep: Long) extends Command + /** Advance total battle experience points */ + final case class Progress(bep: Long) extends Command + /** Award command experience points */ - final case class AwardCep(bep: Long) extends Command + final case class AwardFacilityCaptureBep(bep: Long) extends Command + + /** Award command experience points */ + final case class AwardCep(cep: Long) extends Command /** Set total command experience points */ - final case class SetCep(bep: Long) extends Command + final case class SetCep(cep: Long) extends Command /** Set cosmetics. Only allowed for BR24 or higher. */ final case class SetCosmetics(personalStyles: Set[Cosmetic]) extends Command @@ -226,6 +239,8 @@ object AvatarActor { final case class AvatarLoginResponse(avatar: Avatar) + final case class SupportExperienceDeposit(bep: Long, delay: Long) extends Command + /** * A player loadout represents all of the items in the player's hands (equipment slots) * and all of the items in the player's backpack (inventory) @@ -822,6 +837,110 @@ object AvatarActor { out.future } + def setBepOnly(avatarId: Long, bep: Long): Future[Long] = { + import ctx._ + import scala.concurrent.ExecutionContext.Implicits.global + val out: Promise[Long] = Promise() + val result = ctx.run(query[persistence.Avatar].filter(_.id == lift(avatarId)).update(_.bep -> lift(bep))) + result.onComplete { _ => + out.completeWith(Future(bep)) + } + out.future + } + + def loadExperienceDebt(avatarId: Long): Future[Long] = { + import ctx._ + import scala.concurrent.ExecutionContext.Implicits.global + val out: Promise[Long] = Promise() + val result = ctx.run(query[persistence.Progressiondebt].filter(_.avatarId == lift(avatarId))) + result.onComplete { + case Success(debt) if debt.nonEmpty => + out.completeWith(Future(debt.head.experience)) + case _ => + out.completeWith(Future(0L)) + } + out.future + } + + def saveExperienceDebt(avatarId: Long, exp: Long): Future[Int] = { + import ctx._ + import scala.concurrent.ExecutionContext.Implicits.global + val out: Promise[Int] = Promise() + val result = ctx.run(query[persistence.Progressiondebt].filter(_.avatarId == lift(avatarId))) + result.onComplete { + case Success(debt) if debt.nonEmpty => + ctx.run( + query[persistence.Progressiondebt] + .filter(_.avatarId == lift(avatarId)) + .update(_.experience -> lift(exp)) + ) + out.completeWith(Future(1)) + case _ => + out.completeWith(Future(0)) + } + out.future + } + + def avatarNoLongerLoggedIn(accountId: Long): Unit = { + import ctx._ + import scala.concurrent.ExecutionContext.Implicits.global //linter says unused but compiler says otherwise + ctx.run( + query[persistence.Account] + .filter(_.id == lift(accountId)) + .update(_.avatarLoggedIn -> lift(0L)) + ) + } + + def updateToolDischargeFor(avatar: Avatar): Unit = { + updateToolDischargeFor(avatar.id, avatar.scorecard.CurrentLife) + } + + def updateToolDischargeFor(avatarId: Long, life: Life): Unit = { + updateToolDischargeFor(avatarId, life.equipmentStats) + } + + def updateToolDischargeFor(avatarId: Long, stats: Seq[EquipmentStat]): Unit = { + stats.foreach { stat => + zones.exp.ToDatabase.reportToolDischarge(avatarId, stat) + } + } + + def loadCampaignKdaData(avatarId: Long): Future[ScoreCard] = { + import ctx._ + import scala.concurrent.ExecutionContext.Implicits.global + val out: Promise[ScoreCard] = Promise() + val result = ctx.run(quote { + for { + kdaOut <- query[persistence.Killactivity] + .filter(c => + (c.killerId == lift(avatarId) || c.victimId == lift(avatarId)) && c.killerId != c.victimId + ) + .join(query[persistence.Avatar]) + .on({ case (a, b) => + b.id == a.victimId + }) + .map({ case (a, b) => + (a.killerId, a.victimId, a.victimExosuit, b.factionId) + }) + } yield kdaOut + }) + result.onComplete { + case Success(res) => + val card = new ScoreCard() + val (killerEntries, _) = res.partition { + case (killer, _, _, _) => avatarId == killer + } + killerEntries.foreach { case (_, _, exosuit, faction) => + val statId = StatisticalElement.relatedElement(ExoSuitType(exosuit)).value + card.initStatisticForKill(statId, PlanetSideEmpire(faction)) + } + out.completeWith(Future(card)) + case _ => + out.completeWith(Future(new ScoreCard())) + } + out.future + } + def toAvatar(avatar: persistence.Avatar): Avatar = { val bep = avatar.bep val convertedCosmetics = if (BattleRank.showCosmetics(bep)) { @@ -863,6 +982,9 @@ class AvatarActor( var _avatar: Option[Avatar] = None var saveLockerFunc: () => Unit = storeNewLocker //val topic: ActorRef[Topic.Command[Avatar]] = context.spawnAnonymous(Topic[Avatar]("avatar")) + var supportExperiencePool: Long = 0 + var supportExperienceTimer: Cancellable = Default.Cancellable + var experienceDebt: Long = 0L def avatar: Avatar = _avatar.get @@ -1024,13 +1146,13 @@ class AvatarActor( } yield true inits.onComplete { case Success(_) => - performAvatarLogin(avatarId, account.id, replyTo) + performAvatarLoginTest(avatarId, account.id, replyTo) case Failure(e) => log.error(e)("db failure") sessionActor ! SessionActor.SendResponse(ActionResultMessage.Fail(error = 6)) } } else { - performAvatarLogin(avatarId, account.id, replyTo) + performAvatarLoginTest(avatarId, account.id, replyTo) } case Success(_) => //TODO this may not be an actual failure, but don't know what to do @@ -1049,6 +1171,11 @@ class AvatarActor( buffer.stash(other) Behaviors.same } + .receiveSignal { + case (_, PostStop) => + AvatarActor.avatarNoLongerLoggedIn(account.id) + Behaviors.same + } } def postCharacterSelectBehaviour(): Behavior[Command] = { @@ -1646,24 +1773,95 @@ class AvatarActor( updateToolDischarge(stats) Behaviors.same - case UpdateKillsDeathsAssists(stat) => - updateKillsDeathsAssists(stat) + case UpdateKillsDeathsAssists(stat: Kill) => + updateKills(stat) + Behaviors.same + + case UpdateKillsDeathsAssists(stat: Assist) => + updateAssists(stat) + Behaviors.same + + case UpdateKillsDeathsAssists(stat: Death) => + updateDeaths(stat) + Behaviors.same + + case UpdateKillsDeathsAssists(stat: SupportActivity) => + updateSupport(stat) + Behaviors.same + + case AwardBep(bep, ExperienceType.Support) => + val gain = bep - experienceDebt + if (gain > 0L) { + awardSupportExperience(gain, previousDelay = 0L) + } else { + experienceDebt = experienceDebt - bep + } Behaviors.same case AwardBep(bep, modifier) => - setBep(avatar.bep + bep, modifier) + val mod = Config.app.game.promotion.battleExperiencePointsModifier + if (experienceDebt == 0L) { + setBep(avatar.bep + bep, modifier) + } else if (mod > 0f) { + val modifiedBep = (bep.toFloat * Config.app.game.promotion.battleExperiencePointsModifier).toLong + val gain = modifiedBep - experienceDebt + if (gain > 0L) { + setBep(avatar.bep + gain, modifier) + } else { + experienceDebt = experienceDebt - modifiedBep + } + } + Behaviors.same + + case AwardFacilityCaptureBep(bep) => + val gain = bep - experienceDebt + if (gain > 0L) { + setBep(gain, ExperienceType.Normal) + } else { + experienceDebt = experienceDebt - bep + } + Behaviors.same + + case SupportExperienceDeposit(bep, delayBy) => + actuallyAwardSupportExperience(bep, delayBy) Behaviors.same case SetBep(bep) => setBep(bep, ExperienceType.Normal) + sessionActor ! SessionActor.SendResponse(ChatMsg(ChatMessageType.UNK_229, "@AckSuccessSetBattleRank")) + Behaviors.same + + case Progress(bep) => + if ({ + val oldBr = BattleRank.withExperience(avatar.bep).value + val newBr = BattleRank.withExperience(bep).value + if (Config.app.game.promotion.active && oldBr == 1 && newBr > 1 && newBr < Config.app.game.promotion.maxBattleRank + 1) { + experienceDebt = bep + if (avatar.cep > 0) { + setCep(0L) + } + true + } else if (experienceDebt > 0 && newBr == 2) { + experienceDebt = 0 + true + } else { + false + } + }) { + setBep(bep, ExperienceType.Normal) + sessionActor ! SessionActor.SendResponse(ChatMsg(ChatMessageType.UNK_229, "@AckSuccessSetBattleRank")) + } Behaviors.same case AwardCep(cep) => - setCep(avatar.cep + cep) + if (experienceDebt > 0L) { + setCep(avatar.cep + cep) + } Behaviors.same case SetCep(cep) => setCep(cep) + sessionActor ! SessionActor.SendResponse(ChatMsg(ChatMessageType.UNK_229, "@AckSuccessSetCommandRank")) Behaviors.same case SetCosmetics(cosmetics) => @@ -1789,10 +1987,17 @@ class AvatarActor( } .receiveSignal { case (_, PostStop) => - AvatarActor.saveAvatarData(avatar) staminaRegenTimer.cancel() implantTimers.values.foreach(_.cancel()) + supportExperienceTimer.cancel() + if (supportExperiencePool > 0) { + AvatarActor.setBepOnly(avatar.id, avatar.bep + supportExperiencePool) + } + AvatarActor.saveAvatarData(avatar) saveLockerFunc() + AvatarActor.updateToolDischargeFor(avatar) + AvatarActor.saveExperienceDebt(avatar.id, experienceDebt) + AvatarActor.avatarNoLongerLoggedIn(account.get.id) Behaviors.same } } @@ -1805,9 +2010,26 @@ class AvatarActor( Future.failed(ex).asInstanceOf[Future[Loadout]] } + def performAvatarLoginTest(avatarId: Long, accountId: Long, replyTo: ActorRef[AvatarLoginResponse]): Unit = { + import ctx._ + val blockLogInIfNot = for { + out <- ctx.run(query[persistence.Account].filter(_.id == lift(accountId))) + } yield out + blockLogInIfNot.onComplete { + case Success(account) + //TODO test against acceptable player factions + if account.exists { _.avatarLoggedIn == 0 } => + //accept + performAvatarLogin(avatarId, accountId, replyTo) + case _ => + //refuse + //TODO refuse? + sessionActor ! SessionActor.Quit() + } + } + def performAvatarLogin(avatarId: Long, accountId: Long, replyTo: ActorRef[AvatarLoginResponse]): Unit = { import ctx._ - val result = for { //log this login _ <- ctx.run( @@ -1819,7 +2041,10 @@ class AvatarActor( _ <- ctx.run( query[persistence.Account] .filter(_.id == lift(accountId)) - .update(_.lastFactionId -> lift(avatar.faction.id)) + .update( + _.lastFactionId -> lift(avatar.faction.id), + _.avatarLoggedIn -> lift(avatarId) + ) ) //retrieve avatar data loadouts <- initializeAllLoadouts() @@ -1830,9 +2055,11 @@ class AvatarActor( ignored <- loadIgnoredList(avatarId) shortcuts <- loadShortcuts(avatarId) saved <- AvatarActor.loadSavedAvatarData(avatarId) - } yield (loadouts, implants, certs, locker, friends, ignored, shortcuts, saved) + debt <- AvatarActor.loadExperienceDebt(avatarId) + card <- AvatarActor.loadCampaignKdaData(avatarId) + } yield (loadouts, implants, certs, locker, friends, ignored, shortcuts, saved, debt, card) result.onComplete { - case Success((_loadouts, implants, certs, lockerInv, friendsList, ignoredList, shortcutList, saved)) => + case Success((_loadouts, implants, certs, lockerInv, friendsList, ignoredList, shortcutList, saved, debt, card)) => //shortcuts must have a hotbar option for each implant // val implantShortcuts = shortcutList.filter { // case Some(e) => e.purpose == 0 @@ -1866,11 +2093,13 @@ class AvatarActor( cooldowns = Cooldowns( purchase = AvatarActor.buildCooldownsFromClob(saved.purchaseCooldowns, Avatar.purchaseCooldowns, log), use = AvatarActor.buildCooldownsFromClob(saved.useCooldowns, Avatar.useCooldowns, log) - ) + ), + scorecard = card ) ) // if we need to start stamina regeneration tryRestoreStaminaForSession(stamina = 1).collect { _ => defaultStaminaRegen(initialDelay = 0.5f seconds) } + experienceDebt = debt replyTo ! AvatarLoginResponse(avatar) case Failure(e) => log.error(e)("db failure") @@ -2865,13 +3094,7 @@ class AvatarActor( def setBep(bep: Long, modifier: ExperienceType): Unit = { import ctx._ - val current = BattleRank.withExperience(avatar.bep).value - val next = BattleRank.withExperience(bep).value - lazy val br24 = BattleRank.BR24.value - val result = for { - r <- ctx.run(query[persistence.Avatar].filter(_.id == lift(avatar.id)).update(_.bep -> lift(bep))) - } yield r - result.onComplete { + AvatarActor.setBepOnly(avatar.id, bep).onComplete { case Success(_) => val sess = session.get val zone = sess.zone @@ -2880,6 +3103,9 @@ class AvatarActor( val player = sess.player val pguid = player.GUID val localModifier = modifier + val current = BattleRank.withExperience(avatar.bep).value + val next = BattleRank.withExperience(bep).value + val br24 = BattleRank.BR24.value sessionActor ! SessionActor.SendResponse(BattleExperienceMessage(pguid, bep, localModifier)) events ! AvatarServiceMessage(zoneId, AvatarAction.PlanetsideAttributeToAll(pguid, 17, bep)) if (current < br24 && next >= br24 || current >= br24 && next < br24) { @@ -2936,41 +3162,142 @@ class AvatarActor( } } - def updateKillsDeathsAssists(kdaStat: KDAStat): Unit = { - avatar.scorecard.rate(kdaStat) - val exp = kdaStat.experienceEarned + def awardSupportExperience(bep: Long, previousDelay: Long): Unit = { + setBep(avatar.bep + bep, ExperienceType.Support) //todo simplify support testing +// supportExperiencePool = supportExperiencePool + bep +// avatar.scorecard.rate(bep) +// if (supportExperienceTimer.isCancelled) { +// resetSupportExperienceTimer(previousBep = 0, previousDelay = 0) +// } + } + + def actuallyAwardSupportExperience(bep: Long, delayBy: Long): Unit = { + setBep(avatar.bep + bep, ExperienceType.Support) + supportExperiencePool = supportExperiencePool - bep + if (supportExperiencePool > 0) { + resetSupportExperienceTimer(bep, delayBy) + } else { + supportExperiencePool = 0 + supportExperienceTimer.cancel() + supportExperienceTimer = Default.Cancellable + } + } + + def updateKills(killStat: Kill): Unit = { + val exp = killStat.experienceEarned + val (modifiedExp, msg) = updateExperienceAndType(killStat.experienceEarned) + val output = avatar.scorecard.rate(killStat.copy(experienceEarned = modifiedExp)) + val _session = session.get + val zone = _session.zone + val player = _session.player + val playerSource = PlayerSource(player) + val historyTranscript = { + (killStat.info.interaction.cause match { + case pr: ProjectileReason => pr.projectile.mounted_in.flatMap { a => zone.GUID(a._1) } //what fired the projectile + case _ => None + }).collect { + case mount: PlanetSideGameObject with FactionAffinity with InGameHistory with MountedWeapons => + player.ContributionFrom(mount) + } + player.HistoryAndContributions() + } + zone.actor ! ZoneActor.RewardOurSupporters(playerSource, historyTranscript, killStat, exp) + val target = killStat.info.targetAfter.asInstanceOf[PlayerSource] + val targetMounted = target.seatedIn.map { case (v: VehicleSource, seat) => + val definition = v.Definition + definition.ObjectId * 10 + Vehicles.SeatPermissionGroup(definition, seat).map { _.id }.getOrElse(0) + }.getOrElse(0) + zones.exp.ToDatabase.reportKillBy( + avatar.id.toLong, + target.CharId, + target.ExoSuit.id, + targetMounted, + killStat.info.interaction.cause.attribution, + player.Zone.Number, + target.Position, + modifiedExp + ) + output.foreach { case (id, entry) => + val elem = StatisticalElement.fromId(id) + sessionActor ! SessionActor.SendResponse( + AvatarStatisticsMessage(SessionStatistic( + StatisticalCategory.Destroyed, + elem, + entry.tr_s, + entry.nc_s, + entry.vs_s, + entry.ps_s + )) + ) + } + if (exp > 0L) { + setBep(avatar.bep + exp, msg) + } + } + + def updateDeaths(deathStat: Death): Unit = { + AvatarActor.updateToolDischargeFor(avatar) + avatar.scorecard.rate(deathStat) val _session = session.get val zone = _session.zone val player = _session.player - kdaStat match { - case kill: Kill => - val playerSource = PlayerSource(player) - (kill.info.interaction.cause match { - case pr: ProjectileReason => pr.projectile.mounted_in.map { a => zone.GUID(a._1) } - 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) - case _: Death => - zone.AvatarEvents ! AvatarServiceMessage( - player.Name, - AvatarAction.SendResponse( - Service.defaultPlayerGUID, - AvatarStatisticsMessage(DeathStatistic(avatar.scorecard.Lives.size)) - ) - ) + zone.AvatarEvents ! AvatarServiceMessage( + player.Name, + AvatarAction.SendResponse( + Service.defaultPlayerGUID, + AvatarStatisticsMessage(DeathStatistic(ScoreCard.deathCount(avatar.scorecard))) + ) + ) + } + + def updateAssists(assistStat: Assist): Unit = { + avatar.scorecard.rate(assistStat) + val exp = assistStat.experienceEarned + val _session = session.get + val avatarId = avatar.id.toLong + assistStat.weapons.foreach { wrapper => + zones.exp.ToDatabase.reportKillAssistBy( + avatarId, + assistStat.victim.CharId, + wrapper.equipment, + _session.zone.Number, + assistStat.victim.Position, + exp + ) } - if (exp > 0L) { - val gameOpts = Config.app.game - val (msg, modifier): (ExperienceType, Float) = if (player.Carrying.contains(SpecialCarry.RabbitBall)) { - (ExperienceType.RabbitBall, 1.25f) - } else { - (ExperienceType.Normal, 1f) - } - setBep(avatar.bep + (exp * modifier * gameOpts.bepRate).toLong, msg) + awardSupportExperience(exp, previousDelay = 0L) + } + + def updateSupport(supportStat: SupportActivity): Unit = { + val avatarId = avatar.id.toLong + val target = supportStat.target + val targetId = target.CharId + val targetExosuit = target.ExoSuit.id + val exp = supportStat.experienceEarned + supportStat.weapons.foreach { entry => + zones.exp.ToDatabase.reportSupportBy( + avatarId, + targetId, + targetExosuit, + entry.value, + entry.intermediate, + entry.equipment, + exp + ) } + awardSupportExperience(exp, previousDelay = 0L) + } + + def updateExperienceAndType(exp: Long): (Long, ExperienceType) = { + val _session = session.get + val player = _session.player + val gameOpts = Config.app.game.experience.bep + val (modifier, msg) = if (player.Carrying.contains(SpecialCarry.RabbitBall)) { + (1.25f, ExperienceType.RabbitBall) + } else { + (1f, ExperienceType.Normal) + } + ((exp * modifier * gameOpts.rate).toLong, msg) } def updateToolDischarge(stats: EquipmentStat): Unit = { @@ -3103,4 +3430,41 @@ class AvatarActor( } output.future } + + def resetSupportExperienceTimer(previousBep: Long, previousDelay: Long): Unit = { + val bep: Long = if (supportExperiencePool < 10L) { + supportExperiencePool + } else { + val rand = math.random() + val range: Long = if (previousBep < 30L) { + if (rand < 0.3d) { + 75L + } else { + 215L + } + } else { + if (rand < 0.1d || (previousDelay > 35000L && previousBep > 150L)) { + 75L + } else if (rand > 0.9d) { + 520L + } else { + 125L + } + } + math.min((range * math.random()).toLong, supportExperiencePool) + } + val delay: Long = { + val rand = math.random() + if ((previousBep > 190L || previousDelay > 35000L) && bep < 51L) { + (1000d * rand).toLong + } else { + 10000L + (rand * 35000d).toLong + } + } + supportExperienceTimer = context.scheduleOnce( + delay.milliseconds, + context.self, + SupportExperienceDeposit(bep, delay) + ) + } } diff --git a/src/main/scala/net/psforever/actors/session/ChatActor.scala b/src/main/scala/net/psforever/actors/session/ChatActor.scala index c8c06bf0d..d97b512c3 100644 --- a/src/main/scala/net/psforever/actors/session/ChatActor.scala +++ b/src/main/scala/net/psforever/actors/session/ChatActor.scala @@ -5,6 +5,7 @@ import akka.actor.typed.{ActorRef, Behavior, PostStop, SupervisorStrategy} import akka.actor.typed.receptionist.Receptionist import akka.actor.typed.scaladsl.{ActorContext, Behaviors, StashBuffer} import akka.actor.typed.scaladsl.adapter._ + import scala.collection.mutable import scala.concurrent.ExecutionContextExecutor import scala.concurrent.duration._ @@ -76,7 +77,7 @@ object ChatActor { ): Unit = { if (silos.isEmpty) { session ! SessionActor.SendResponse( - ChatMsg(UNK_229, true, "Server", s"no targets for ntu found with parameters $debugContent", None) + ChatMsg(UNK_229, wideContents=true, "Server", s"no targets for ntu found with parameters $debugContent", None) ) } resources match { @@ -225,7 +226,7 @@ class ChatActor( } sessionActor ! SessionActor.SetFlying(flying) sessionActor ! SessionActor.SendResponse( - ChatMsg(CMT_FLY, false, recipient, if (flying) "on" else "off", None) + ChatMsg(CMT_FLY, wideContents=false, recipient, if (flying) "on" else "off", None) ) case (CMT_ANONYMOUS, _, _) => @@ -282,69 +283,43 @@ class ChatActor( } errorMessage match { case Some(errorMessage) => - sessionActor ! SessionActor.SendResponse( - ChatMsg( - CMT_QUIT, - false, - "", - errorMessage, - None - ) - ) + sessionActor ! SessionActor.SendResponse(ChatMsg(CMT_QUIT, errorMessage)) case None => sessionActor ! SessionActor.Recall() } case (CMT_INSTANTACTION, _, _) => if (session.zoningType == Zoning.Method.Quit) { - sessionActor ! SessionActor.SendResponse( - ChatMsg(CMT_QUIT, false, "", "You can't instant action while quitting.", None) - ) + sessionActor ! SessionActor.SendResponse(ChatMsg(CMT_QUIT, "You can't instant action while quitting.")) } else if (session.zoningType == Zoning.Method.InstantAction) { - sessionActor ! SessionActor.SendResponse( - ChatMsg(CMT_QUIT, false, "", "@noinstantaction_instantactionting", None) - ) + sessionActor ! SessionActor.SendResponse(ChatMsg(CMT_QUIT, "@noinstantaction_instantactionting")) } else if (session.zoningType == Zoning.Method.Recall) { sessionActor ! SessionActor.SendResponse( - ChatMsg( - CMT_QUIT, - false, - "", - "You won't instant action. You already requested to recall to your sanctuary continent", - None - ) + ChatMsg(CMT_QUIT, "You won't instant action. You already requested to recall to your sanctuary continent") ) } else if (!session.player.isAlive || session.deadState != DeadState.Alive) { if (session.player.isAlive) { - sessionActor ! SessionActor.SendResponse( - ChatMsg(CMT_QUIT, false, "", "@noinstantaction_deconstructing", None) - ) + sessionActor ! SessionActor.SendResponse(ChatMsg(CMT_QUIT, "@noinstantaction_deconstructing")) } else { - sessionActor ! SessionActor.SendResponse( - ChatMsg(CMT_QUIT, false, "", "@noinstantaction_dead", None) - ) + sessionActor ! SessionActor.SendResponse(ChatMsg(CMT_QUIT, "@noinstantaction_dead")) } } else if (session.player.VehicleSeated.nonEmpty) { - sessionActor ! SessionActor.SendResponse( - ChatMsg(CMT_QUIT, false, "", "@noinstantaction_invehicle", None) - ) + sessionActor ! SessionActor.SendResponse(ChatMsg(CMT_QUIT, "@noinstantaction_invehicle")) } else { sessionActor ! SessionActor.InstantAction() } case (CMT_QUIT, _, _) => if (session.zoningType == Zoning.Method.Quit) { - sessionActor ! SessionActor.SendResponse(ChatMsg(CMT_QUIT, false, "", "@noquit_quitting", None)) + sessionActor ! SessionActor.SendResponse(ChatMsg(CMT_QUIT, "@noquit_quitting")) } else if (!session.player.isAlive || session.deadState != DeadState.Alive) { if (session.player.isAlive) { - sessionActor ! SessionActor.SendResponse( - ChatMsg(CMT_QUIT, false, "", "@noquit_deconstructing", None) - ) + sessionActor ! SessionActor.SendResponse(ChatMsg(CMT_QUIT, "@noquit_deconstructing")) } else { - sessionActor ! SessionActor.SendResponse(ChatMsg(CMT_QUIT, false, "", "@noquit_dead", None)) + sessionActor ! SessionActor.SendResponse(ChatMsg(CMT_QUIT, "@noquit_dead")) } } else if (session.player.VehicleSeated.nonEmpty) { - sessionActor ! SessionActor.SendResponse(ChatMsg(CMT_QUIT, false, "", "@noquit_invehicle", None)) + sessionActor ! SessionActor.SendResponse(ChatMsg(CMT_QUIT, "@noquit_invehicle")) } else { sessionActor ! SessionActor.Quit() } @@ -514,7 +489,7 @@ class ChatActor( sessionActor ! SessionActor.SendResponse( ChatMsg( UNK_229, - true, + wideContents=true, "", s"\\#FF4040ERROR - \'${args(0)}\' is not a valid building name.", None @@ -524,7 +499,7 @@ class ChatActor( sessionActor ! SessionActor.SendResponse( ChatMsg( UNK_229, - true, + wideContents=true, "", s"\\#FF4040ERROR - \'${args(timerPos.get)}\' is not a valid timer value.", None @@ -610,11 +585,11 @@ class ChatActor( ) } else if (AvatarActor.getLiveAvatarForFunc(message.recipient, (_,_,_)=>{}).isEmpty) { sessionActor ! SessionActor.SendResponse( - ChatMsg(ChatMessageType.UNK_45, false, "none", "@notell_target", None) + ChatMsg(ChatMessageType.UNK_45, wideContents=false, "none", "@notell_target", None) ) } else { sessionActor ! SessionActor.SendResponse( - ChatMsg(ChatMessageType.UNK_45, false, "none", "@notell_ignore", None) + ChatMsg(ChatMessageType.UNK_45, wideContents=false, "none", "@notell_ignore", None) ) } @@ -663,22 +638,20 @@ class ChatActor( val popVS = players.count(_.faction == PlanetSideEmpire.VS) if (popNC + popTR + popVS == 0) { - sessionActor ! SessionActor.SendResponse( - ChatMsg(ChatMessageType.CMT_WHO, false, "", "@Nomatches", None) - ) + sessionActor ! SessionActor.SendResponse(ChatMsg(ChatMessageType.CMT_WHO, "@Nomatches")) } else { val contName = session.zone.map.name sessionActor ! SessionActor.SendResponse( - ChatMsg(ChatMessageType.CMT_WHO, true, "", "That command doesn't work for now, but : ", None) + ChatMsg(ChatMessageType.CMT_WHO, wideContents=true, "", "That command doesn't work for now, but : ", None) ) sessionActor ! SessionActor.SendResponse( - ChatMsg(ChatMessageType.CMT_WHO, true, "", "NC online : " + popNC + " on " + contName, None) + ChatMsg(ChatMessageType.CMT_WHO, wideContents=true, "", "NC online : " + popNC + " on " + contName, None) ) sessionActor ! SessionActor.SendResponse( - ChatMsg(ChatMessageType.CMT_WHO, true, "", "TR online : " + popTR + " on " + contName, None) + ChatMsg(ChatMessageType.CMT_WHO, wideContents=true, "", "TR online : " + popTR + " on " + contName, None) ) sessionActor ! SessionActor.SendResponse( - ChatMsg(ChatMessageType.CMT_WHO, true, "", "VS online : " + popVS + " on " + contName, None) + ChatMsg(ChatMessageType.CMT_WHO, wideContents=true, "", "VS online : " + popVS + " on " + contName, None) ) } @@ -702,16 +675,16 @@ class ChatActor( } (zone, gate, list) match { case (None, None, true) => - sessionActor ! SessionActor.SendResponse(ChatMsg(UNK_229, true, "", PointOfInterest.list, None)) + sessionActor ! SessionActor.SendResponse(ChatMsg(UNK_229, wideContents=true, "", PointOfInterest.list, None)) case (Some(zone), None, true) => sessionActor ! SessionActor.SendResponse( - ChatMsg(UNK_229, true, "", PointOfInterest.listWarpgates(zone), None) + ChatMsg(UNK_229, wideContents=true, "", PointOfInterest.listWarpgates(zone), None) ) case (Some(zone), Some(gate), false) => sessionActor ! SessionActor.SetZone(zone.zonename, gate) case (_, None, false) => sessionActor ! SessionActor.SendResponse( - ChatMsg(UNK_229, true, "", "Gate id not defined (use '/zone -list')", None) + ChatMsg(UNK_229, wideContents=true, "", "Gate id not defined (use '/zone -list')", None) ) case (_, _, _) if buffer.isEmpty || buffer(0).equals("-help") => sessionActor ! SessionActor.SendResponse( @@ -740,16 +713,16 @@ class ChatActor( zone match { case Some(zone: PointOfInterest) => sessionActor ! SessionActor.SendResponse( - ChatMsg(UNK_229, true, "", PointOfInterest.listAll(zone), None) + ChatMsg(UNK_229, wideContents=true, "", PointOfInterest.listAll(zone), None) ) - case _ => ChatMsg(UNK_229, true, "", s"unknown player zone '${session.player.Zone.id}'", None) + case _ => ChatMsg(UNK_229, wideContents=true, "", s"unknown player zone '${session.player.Zone.id}'", None) } case (None, Some(waypoint)) if waypoint != "-help" => PointOfInterest.getWarpLocation(session.zone.id, waypoint) match { case Some(location) => sessionActor ! SessionActor.SetPosition(location) case None => sessionActor ! SessionActor.SendResponse( - ChatMsg(UNK_229, true, "", s"unknown location '$waypoint'", None) + ChatMsg(UNK_229, wideContents=true, "", s"unknown location '$waypoint'", None) ) } case _ => @@ -759,60 +732,10 @@ class ChatActor( } case (CMT_SETBATTLERANK, _, contents) if gmCommandAllowed => - val buffer = contents.toLowerCase.split("\\s+") - val (target, rank) = (buffer.lift(0), buffer.lift(1)) match { - case (Some(target), Some(rank)) if target == session.avatar.name => - rank.toIntOption match { - case Some(rank) => (None, BattleRank.withValueOpt(rank)) - case None => (None, None) - } - case (Some(target), Some(rank)) => - // picking other targets is not supported for now - (None, None) - case (Some(rank), None) => - rank.toIntOption match { - case Some(rank) => (None, BattleRank.withValueOpt(rank)) - case None => (None, None) - } - case _ => (None, None) - } - (target, rank) match { - case (_, Some(rank)) => - avatarActor ! AvatarActor.SetBep(rank.experience) - sessionActor ! SessionActor.SendResponse(message.copy(contents = "@AckSuccessSetBattleRank")) - case _ => - sessionActor ! SessionActor.SendResponse( - message.copy(messageType = UNK_229, contents = "@CMT_SETBATTLERANK_usage") - ) - } + setBattleRank(message, contents, session, AvatarActor.SetBep) case (CMT_SETCOMMANDRANK, _, contents) if gmCommandAllowed => - val buffer = contents.toLowerCase.split("\\s+") - val (target, rank) = (buffer.lift(0), buffer.lift(1)) match { - case (Some(target), Some(rank)) if target == session.avatar.name => - rank.toIntOption match { - case Some(rank) => (None, CommandRank.withValueOpt(rank)) - case None => (None, None) - } - case (Some(target), Some(rank)) => - // picking other targets is not supported for now - (None, None) - case (Some(rank), None) => - rank.toIntOption match { - case Some(rank) => (None, CommandRank.withValueOpt(rank)) - case None => (None, None) - } - case _ => (None, None) - } - (target, rank) match { - case (_, Some(rank)) => - avatarActor ! AvatarActor.SetCep(rank.experience) - sessionActor ! SessionActor.SendResponse(message.copy(contents = "@AckSuccessSetCommandRank")) - case _ => - sessionActor ! SessionActor.SendResponse( - message.copy(messageType = UNK_229, contents = "@CMT_SETCOMMANDRANK_usage") - ) - } + setCommandRank(message, contents, session) case (CMT_ADDBATTLEEXPERIENCE, _, contents) if gmCommandAllowed => contents.toIntOption match { @@ -1031,20 +954,20 @@ class ChatActor( if (session.player.silenced) { sessionActor ! SessionActor.SetSilenced(false) sessionActor ! SessionActor.SendResponse( - ChatMsg(ChatMessageType.UNK_229, true, "", "@silence_off", None) + ChatMsg(ChatMessageType.UNK_229, wideContents=true, "", "@silence_off", None) ) if (!silenceTimer.isCancelled) silenceTimer.cancel() } else { sessionActor ! SessionActor.SetSilenced(true) sessionActor ! SessionActor.SendResponse( - ChatMsg(ChatMessageType.UNK_229, true, "", "@silence_on", None) + ChatMsg(ChatMessageType.UNK_229, wideContents=true, "", "@silence_on", None) ) silenceTimer = context.system.scheduler.scheduleOnce( time minutes, () => { sessionActor ! SessionActor.SetSilenced(false) sessionActor ! SessionActor.SendResponse( - ChatMsg(ChatMessageType.UNK_229, true, "", "@silence_timeout", None) + ChatMsg(ChatMessageType.UNK_229, wideContents=true, "", "@silence_timeout", None) ) } ) @@ -1186,7 +1109,7 @@ class ChatActor( if (contents.startsWith("!whitetext ") && gmCommandAllowed) { chatService ! ChatService.Message( session, - ChatMsg(UNK_227, true, "", contents.replace("!whitetext ", ""), None), + ChatMsg(UNK_227, wideContents=true, "", contents.replace("!whitetext ", ""), None), ChatChannel.Default() ) true @@ -1350,6 +1273,13 @@ class ChatActor( case _ => false } + } else if (contents.startsWith("!progress")) { + if (!session.account.gm && BattleRank.withExperience(session.avatar.bep).value < Config.app.game.promotion.maxBattleRank + 1) { + setBattleRank(message, contents, session, AvatarActor.Progress) + true + } else { + false + } } else { false // unknown ! commands are ignored } @@ -1357,4 +1287,66 @@ class ChatActor( false // unknown ! commands are ignored } } + + def setBattleRank( + message: ChatMsg, + contents: String, + session: Session, + msgFunc: Long => AvatarActor.Command + ): Unit = { + val buffer = contents.toLowerCase.split("\\s+") + val (target, rank) = (buffer.lift(0), buffer.lift(1)) match { + case (Some(target), Some(rank)) if target == session.avatar.name => + rank.toIntOption match { + case Some(rank) => (None, BattleRank.withValueOpt(rank)) + case None => (None, None) + } + case (Some(_), Some(_)) => + // picking other targets is not supported for now + (None, None) + case (Some(rank), None) => + rank.toIntOption match { + case Some(rank) => (None, BattleRank.withValueOpt(rank)) + case None => (None, None) + } + case _ => (None, None) + } + (target, rank) match { + case (_, Some(rank)) if rank.value <= Config.app.game.maxBattleRank => + avatarActor ! msgFunc(rank.experience) + case _ => + sessionActor ! SessionActor.SendResponse( + message.copy(messageType = UNK_229, contents = "@CMT_SETBATTLERANK_usage") + ) + } + } + + def setCommandRank(message: ChatMsg, contents: String, session: Session): Unit = { + val buffer = contents.toLowerCase.split("\\s+") + val (target, rank) = (buffer.lift(0), buffer.lift(1)) match { + case (Some(target), Some(rank)) if target == session.avatar.name => + rank.toIntOption match { + case Some(rank) => (None, CommandRank.withValueOpt(rank)) + case None => (None, None) + } + case (Some(_), Some(_)) => + // picking other targets is not supported for now + (None, None) + case (Some(rank), None) => + rank.toIntOption match { + case Some(rank) => (None, CommandRank.withValueOpt(rank)) + case None => (None, None) + } + case _ => (None, None) + } + (target, rank) match { + case (_, Some(rank)) => + avatarActor ! AvatarActor.SetCep(rank.experience) + //sessionActor ! SessionActor.SendResponse(message.copy(contents = "@AckSuccessSetCommandRank")) + case _ => + sessionActor ! SessionActor.SendResponse( + message.copy(messageType = UNK_229, contents = "@CMT_SETCOMMANDRANK_usage") + ) + } + } } 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 dd08f21ed..ee6055c46 100644 --- a/src/main/scala/net/psforever/actors/session/support/SessionAvatarHandlers.scala +++ b/src/main/scala/net/psforever/actors/session/support/SessionAvatarHandlers.scala @@ -3,8 +3,11 @@ package net.psforever.actors.session.support import akka.actor.typed.scaladsl.adapter._ import akka.actor.{ActorContext, typed} +import net.psforever.objects.avatar.SpecialCarry +import net.psforever.objects.serverobject.llu.CaptureFlag import net.psforever.packet.game.objectcreate.ConstructorData import net.psforever.services.Service +import net.psforever.objects.zones.exp import scala.collection.mutable import scala.concurrent.ExecutionContext.Implicits.global @@ -225,8 +228,6 @@ class SessionAvatarHandlers( case AvatarResponse.DestroyDisplay(killer, victim, method, unk) if killer.CharId == avatar.id && killer.Faction != victim.Faction => sendResponse(sessionData.destroyDisplayMessage(killer, victim, method, unk)) - //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 @@ -398,6 +399,80 @@ class SessionAvatarHandlers( case AvatarResponse.UpdateKillsDeathsAssists(_, kda) => avatarActor ! AvatarActor.UpdateKillsDeathsAssists(kda) + case AvatarResponse.AwardBep(charId, bep, expType) => + if (charId == player.CharId) { + avatarActor ! AvatarActor.AwardBep(bep, expType) + } + + case AvatarResponse.AwardCep(charId, cep) => + //if the target player, always award (some) CEP + if (charId == player.CharId) { + avatarActor ! AvatarActor.AwardCep(cep) + } + + case AvatarResponse.FacilityCaptureRewards(buildingId, zoneNumber, cep) => + //must be in a squad to earn experience + val cepConfig = Config.app.game.experience.cep + val charId = player.CharId + val squadUI = sessionData.squad.squadUI + val participation = continent + .Building(buildingId) + .map { building => + building.Participation.PlayerContribution() + } + squadUI + .find { _._1 == charId } + .collect { + case (_, elem) if elem.index == 0 => + //squad leader earns CEP, modified by squad effort, capped by squad size present during the capture + val squadParticipation = participation match { + case Some(map) => map.filter { case (id, _) => squadUI.contains(id) } + case _ => Map.empty[Long, Float] + } + val maxCepBySquadSize: Long = { + val maxCepList = cepConfig.maximumPerSquadSize + val squadSize: Int = squadParticipation.size + maxCepList.lift(squadSize - 1).getOrElse(squadSize * maxCepList.head).toLong + } + val groupContribution: Float = squadUI + .map { case (id, _) => (id, squadParticipation.getOrElse(id, 0f) / 10f) } + .values + .max + val modifiedExp: Long = (cep.toFloat * groupContribution).toLong + val cappedModifiedExp: Long = math.min(modifiedExp, maxCepBySquadSize) + val finalExp: Long = if (modifiedExp > cappedModifiedExp) { + val overLimitOverflow = if (cepConfig.squadSizeLimitOverflow == -1) { + cep.toFloat + } else { + cepConfig.squadSizeLimitOverflow.toFloat + } + cappedModifiedExp + (overLimitOverflow * cepConfig.squadSizeLimitOverflowMultiplier).toLong + } else { + cappedModifiedExp + } + exp.ToDatabase.reportFacilityCapture(charId, buildingId, zoneNumber, finalExp, expType="cep") + avatarActor ! AvatarActor.AwardCep(finalExp) + Some(finalExp) + + case _ => + //squad member earns BEP based on CEP, modified by personal effort + val individualContribution = { + val contributionList = for { + facilityMap <- participation + if facilityMap.contains(charId) + } yield facilityMap(charId) + if (contributionList.nonEmpty) { + contributionList.max + } else { + 0f + } + } + val modifiedExp = (cep * individualContribution).toLong + exp.ToDatabase.reportFacilityCapture(charId, buildingId, zoneNumber, modifiedExp, expType="bep") + avatarActor ! AvatarActor.AwardFacilityCaptureBep(modifiedExp) + Some(modifiedExp) + } + case AvatarResponse.SendResponse(msg) => sendResponse(msg) @@ -412,16 +487,35 @@ class SessionAvatarHandlers( case AvatarResponse.Killed(mount) => //log and chat messages val cause = player.LastDamage.flatMap { damage => - damage.interaction.cause match { - case cause: ExplodingEntityReason if cause.entity.isInstanceOf[VehicleSpawnPad] => + val interaction = damage.interaction + val reason = interaction.cause + val adversarial = interaction.adversarial.map { _.attacker } + reason match { + case r: ExplodingEntityReason if r.entity.isInstanceOf[VehicleSpawnPad] => //also, @SVCP_Killed_TooCloseToPadOnCreate^n~ or "... within n meters of pad ..." sendResponse(ChatMsg(ChatMessageType.UNK_227, "@SVCP_Killed_OnPadOnCreate")) case _ => () } - damage match { - case damage if damage.adversarial.nonEmpty => Some(damage.adversarial.get.attacker.Name) - case damage => Some(s"a ${damage.interaction.cause.getClass.getSimpleName}") + adversarial.collect { + case attacker + if player.Carrying.contains(SpecialCarry.CaptureFlag) && + attacker.Faction != player.Faction && + sessionData + .specialItemSlotGuid + .flatMap { continent.GUID } + .collect { + case llu: CaptureFlag => + System.currentTimeMillis() - llu.LastCollectionTime > Config.app.game.experience.cep.lluSlayerCreditDuration.toMillis + case _ => + false + } + .contains(true) => + continent.AvatarEvents ! AvatarServiceMessage( + attacker.Name, + AvatarAction.AwardCep(attacker.CharId, Config.app.game.experience.cep.lluSlayerCredit) + ) } + adversarial.map {_.Name }.orElse { Some(s"a ${reason.getClass.getSimpleName}") } }.getOrElse { s"an unfortunate circumstance (probably ${player.Sex.pronounObject} own fault)" } log.info(s"${player.Name} has died, killed by $cause") if (sessionData.shooting.shotsWhileDead > 0) { @@ -434,6 +528,7 @@ class SessionAvatarHandlers( sessionData.renewCharSavedTimer(fixedLen = 1800L, varLen = 0L) //player state changes + AvatarActor.updateToolDischargeFor(avatar) player.FreeHand.Equipment.foreach { item => DropEquipmentFromInventory(player)(item) } @@ -448,7 +543,6 @@ class SessionAvatarHandlers( } sessionData.playerActionsToCancel() sessionData.terminals.CancelAllProximityUnits() - sessionData.zoning AvatarActor.savePlayerLocation(player) sessionData.zoning.spawn.shiftPosition = Some(player.Position) diff --git a/src/main/scala/net/psforever/actors/session/support/SessionData.scala b/src/main/scala/net/psforever/actors/session/support/SessionData.scala index 2aa1feb42..712b71dfe 100644 --- a/src/main/scala/net/psforever/actors/session/support/SessionData.scala +++ b/src/main/scala/net/psforever/actors/session/support/SessionData.scala @@ -3,7 +3,7 @@ package net.psforever.actors.session.support import akka.actor.typed.scaladsl.adapter._ import akka.actor.{ActorContext, ActorRef, Cancellable, typed} -import net.psforever.objects.sourcing.{PlayerSource, SourceEntry} +import net.psforever.objects.sourcing.{PlayerSource, SourceEntry, VehicleSource} import net.psforever.objects.zones.blockmap.{SectorGroup, SectorPopulation} import scala.collection.mutable @@ -411,10 +411,10 @@ class SessionData( /* line 2: vehicle is not mounted in anything or, if it is, its seats are empty */ if ( (session.account.gm || - (player.avatar.vehicle.contains(objectGuid) && vehicle.Owner.contains(player.GUID)) || + (player.avatar.vehicle.contains(objectGuid) && vehicle.OwnerGuid.contains(player.GUID)) || (player.Faction == vehicle.Faction && (vehicle.Definition.CanBeOwned.nonEmpty && - (vehicle.Owner.isEmpty || continent.GUID(vehicle.Owner.get).isEmpty) || vehicle.Destroyed))) && + (vehicle.OwnerGuid.isEmpty || continent.GUID(vehicle.OwnerGuid.get).isEmpty) || vehicle.Destroyed))) && (vehicle.MountedIn.isEmpty || !vehicle.Seats.values.exists(_.isOccupied)) ) { vehicle.Actor ! Vehicle.Deconstruct() @@ -442,7 +442,7 @@ class SessionData( } case Some(obj: Deployable) => - if (session.account.gm || obj.Owner.isEmpty || obj.Owner.contains(player.GUID) || obj.Destroyed) { + if (session.account.gm || obj.OwnerGuid.isEmpty || obj.OwnerGuid.contains(player.GUID) || obj.Destroyed) { obj.Actor ! Deployable.Deconstruct() } else { log.warn(s"RequestDestroy: ${player.Name} must own the deployable in order to deconstruct it") @@ -1377,7 +1377,7 @@ class SessionData( //access to trunk if ( obj.AccessingTrunk.isEmpty && - (!obj.PermissionGroup(AccessPermissionGroup.Trunk.id).contains(VehicleLockState.Locked) || obj.Owner + (!obj.PermissionGroup(AccessPermissionGroup.Trunk.id).contains(VehicleLockState.Locked) || obj.OwnerGuid .contains(player.GUID)) ) { log.info(s"${player.Name} is looking in the ${obj.Definition.Name}'s trunk") @@ -2458,9 +2458,9 @@ class SessionData( src: PlanetSideGameObject with TelepadLike, dest: PlanetSideGameObject with TelepadLike ): Unit = { - val time = System.nanoTime + val time = System.currentTimeMillis() if ( - time - recentTeleportAttempt > (2 seconds).toNanos && router.DeploymentState == DriveState.Deployed && + time - recentTeleportAttempt > 2000L && router.DeploymentState == DriveState.Deployed && internalTelepad.Active && remoteTelepad.Active ) { @@ -2473,6 +2473,11 @@ class SessionData( continent.id, LocalAction.RouterTelepadTransport(pguid, pguid, sguid, dguid) ) + val vSource = VehicleSource(router) + val zoneNumber = continent.Number + player.LogActivity(VehicleMountActivity(vSource, PlayerSource(player), zoneNumber)) + player.Position = dest.Position + player.LogActivity(VehicleDismountActivity(vSource, PlayerSource(player), zoneNumber)) } else { log.warn(s"UseRouterTelepadSystem: ${player.Name} can not teleport") } diff --git a/src/main/scala/net/psforever/actors/session/support/SessionMountHandlers.scala b/src/main/scala/net/psforever/actors/session/support/SessionMountHandlers.scala index 261b1fed9..279bc78d0 100644 --- a/src/main/scala/net/psforever/actors/session/support/SessionMountHandlers.scala +++ b/src/main/scala/net/psforever/actors/session/support/SessionMountHandlers.scala @@ -2,6 +2,9 @@ package net.psforever.actors.session.support import akka.actor.{ActorContext, typed} +import net.psforever.objects.serverobject.affinity.FactionAffinity +import net.psforever.objects.vital.InGameHistory + import scala.concurrent.duration._ // import net.psforever.actors.session.AvatarActor @@ -160,7 +163,7 @@ class SessionMountHandlers( s"MountVehicleMsg: ${tplayer.Name} wants to mount turret ${obj.GUID.guid}, but needs to wait until it finishes updating" ) - case Mountable.CanMount(obj: PlanetSideGameObject with WeaponTurret, seatNumber, _) => + case Mountable.CanMount(obj: PlanetSideGameObject with FactionAffinity with WeaponTurret with InGameHistory, seatNumber, _) => sessionData.zoning.CancelZoningProcessWithDescriptiveReason("cancel_mount") log.info(s"${player.Name} mounts the ${obj.Definition.asInstanceOf[BasicDefinition].Name}") sendResponse(PlanetsideAttributeMessage(obj.GUID, attribute_type=0, obj.Health)) @@ -246,7 +249,7 @@ class SessionMountHandlers( VehicleAction.KickPassenger(tplayer.GUID, seat_num, unk2=true, obj.GUID) ) - case Mountable.CanDismount(obj: PlanetSideGameObject with WeaponTurret, seatNum, _) => + case Mountable.CanDismount(obj: PlanetSideGameObject with PlanetSideGameObject with Mountable with FactionAffinity with InGameHistory, seatNum, _) => log.info(s"${tplayer.Name} dismounts a ${obj.Definition.asInstanceOf[ObjectDefinition].Name}") DismountAction(tplayer, obj, seatNum) @@ -276,7 +279,7 @@ class SessionMountHandlers( * @param obj the mountable object * @param seatNum the mount into which the player is mounting */ - def MountingAction(tplayer: Player, obj: PlanetSideGameObject with Mountable, seatNum: Int): Unit = { + def MountingAction(tplayer: Player, obj: PlanetSideGameObject with FactionAffinity with InGameHistory, seatNum: Int): Unit = { val playerGuid: PlanetSideGUID = tplayer.GUID val objGuid: PlanetSideGUID = obj.GUID sessionData.playerActionsToCancel() @@ -295,7 +298,7 @@ class SessionMountHandlers( * @param obj the mountable object * @param seatNum the mount out of which which the player is disembarking */ - def DismountVehicleAction(tplayer: Player, obj: PlanetSideGameObject with Mountable, seatNum: Int): Unit = { + def DismountVehicleAction(tplayer: Player, obj: PlanetSideGameObject with FactionAffinity with InGameHistory, seatNum: Int): Unit = { DismountAction(tplayer, obj, seatNum) //until vehicles maintain synchronized momentum without a driver obj match { @@ -332,8 +335,9 @@ class SessionMountHandlers( * @param obj the mountable object * @param seatNum the mount out of which which the player is disembarking */ - def DismountAction(tplayer: Player, obj: PlanetSideGameObject with Mountable, seatNum: Int): Unit = { + def DismountAction(tplayer: Player, obj: PlanetSideGameObject with FactionAffinity with InGameHistory, seatNum: Int): Unit = { val playerGuid: PlanetSideGUID = tplayer.GUID + tplayer.ContributionFrom(obj) sessionData.keepAliveFunc = sessionData.zoning.NormalKeepAlive val bailType = if (tplayer.BailProtection) { BailType.Bailed diff --git a/src/main/scala/net/psforever/actors/session/support/SessionTerminalHandlers.scala b/src/main/scala/net/psforever/actors/session/support/SessionTerminalHandlers.scala index de2845420..636f9726d 100644 --- a/src/main/scala/net/psforever/actors/session/support/SessionTerminalHandlers.scala +++ b/src/main/scala/net/psforever/actors/session/support/SessionTerminalHandlers.scala @@ -2,6 +2,9 @@ package net.psforever.actors.session.support import akka.actor.{ActorContext, typed} +import net.psforever.objects.sourcing.AmenitySource +import net.psforever.objects.vital.TerminalUsedActivity + import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future // @@ -31,7 +34,8 @@ class SessionTerminalHandlers( val ItemTransactionMessage(terminalGuid, transactionType, _, itemName, _, _) = pkt continent.GUID(terminalGuid) match { case Some(term: Terminal) if lastTerminalOrderFulfillment => - log.info(s"${player.Name} is submitting an order - $transactionType of $itemName") + val msg: String = if (itemName.nonEmpty) s" of $itemName" else "" + log.info(s"${player.Name} is submitting an order - a $transactionType from a ${term.Definition.Name}$msg") lastTerminalOrderFulfillment = false sessionData.zoning.CancelZoningProcessWithDescriptiveReason("cancel_use") term.Actor ! Terminal.Request(player, pkt) @@ -68,8 +72,8 @@ class SessionTerminalHandlers( order match { case Terminal.BuyEquipment(item) if tplayer.avatar.purchaseCooldown(item.Definition).nonEmpty => - lastTerminalOrderFulfillment = true sendResponse(ItemTransactionResultMessage(msg.terminal_guid, TransactionType.Buy, success = false)) + lastTerminalOrderFulfillment = true case Terminal.BuyEquipment(item) => avatarActor ! AvatarActor.UpdatePurchaseTime(item.Definition) @@ -141,6 +145,7 @@ class SessionTerminalHandlers( if (GlobalDefinitions.isBattleFrameVehicle(vehicle.Definition)) { sendResponse(UnuseItemMessage(player.GUID, msg.terminal_guid)) } + player.LogActivity(TerminalUsedActivity(AmenitySource(term), msg.transaction_type)) }.orElse { log.error( s"${tplayer.Name} wanted to spawn a vehicle, but there was no spawn pad associated with terminal ${msg.terminal_guid} to accept it" diff --git a/src/main/scala/net/psforever/actors/session/support/WeaponAndProjectileOperations.scala b/src/main/scala/net/psforever/actors/session/support/WeaponAndProjectileOperations.scala index c9bab2145..34c832e0d 100644 --- a/src/main/scala/net/psforever/actors/session/support/WeaponAndProjectileOperations.scala +++ b/src/main/scala/net/psforever/actors/session/support/WeaponAndProjectileOperations.scala @@ -2,6 +2,8 @@ package net.psforever.actors.session.support import akka.actor.{ActorContext, typed} +import net.psforever.objects.zones.exp.ToDatabase + import scala.collection.mutable import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future @@ -43,7 +45,8 @@ private[support] class WeaponAndProjectileOperations( var prefire: mutable.Set[PlanetSideGUID] = mutable.Set.empty //if WeaponFireMessage precedes ChangeFireStateMessage_Start private[support] var shootingStart: mutable.HashMap[PlanetSideGUID, Long] = mutable.HashMap[PlanetSideGUID, Long]() private[support] var shootingStop: mutable.HashMap[PlanetSideGUID, Long] = mutable.HashMap[PlanetSideGUID, Long]() - private var ongoingShotsFired: Int = 0 + private val shotsFired: mutable.HashMap[Int,Int] = mutable.HashMap[Int,Int]() + private val shotsLanded: mutable.HashMap[Int,Int] = mutable.HashMap[Int,Int]() private[support] var shotsWhileDead: Int = 0 private val projectiles: Array[Option[Projectile]] = Array.fill[Option[Projectile]](Projectile.rangeUID - Projectile.baseUID)(None) @@ -157,7 +160,7 @@ private[support] class WeaponAndProjectileOperations( fireStateStopPlayerMessages(item_guid) case Some(_) => fireStateStopMountedMessages(item_guid) - case _ => () + case _ => log.warn(s"ChangeFireState_Stop: can not find $item_guid") } sessionData.progressBarUpdate.cancel() @@ -275,8 +278,8 @@ private[support] class WeaponAndProjectileOperations( val LongRangeProjectileInfoMessage(guid, _, _) = pkt FindContainedWeapon match { case (Some(_: Vehicle), weapons) - if weapons.exists { _.GUID == guid } => ; //now what? - case _ => ; + if weapons.exists { _.GUID == guid } => () //now what? + case _ => () } } @@ -329,13 +332,11 @@ private[support] class WeaponAndProjectileOperations( _: Vector3, hitPos: Vector3 ) => - ResolveProjectileInteraction(proj, DamageResolution.Hit, target, hitPos) match { - case Some(resprojectile) => - avatarActor ! AvatarActor.UpdateToolDischarge(EquipmentStat(resprojectile.cause.attribution,0,1,0)) - sessionData.handleDealingDamage(target, resprojectile) - case None => ; + ResolveProjectileInteraction(proj, DamageResolution.Hit, target, hitPos).collect { resprojectile => + addShotsLanded(resprojectile.cause.attribution, shots = 1) + sessionData.handleDealingDamage(target, resprojectile) } - case _ => ; + case _ => () } case None => log.warn(s"ResolveProjectile: expected projectile, but ${projectile_guid.guid} not found") @@ -368,26 +369,22 @@ private[support] class WeaponAndProjectileOperations( sessionData.validObject(direct_victim_uid, decorator = "SplashHit/direct_victim") match { case Some(target: PlanetSideGameObject with FactionAffinity with Vitality) => CheckForHitPositionDiscrepancy(projectile_guid, target.Position, target) - ResolveProjectileInteraction(projectile, resolution1, target, target.Position) match { - case Some(resprojectile) => - avatarActor ! AvatarActor.UpdateToolDischarge(EquipmentStat(resprojectile.cause.attribution,0,1,0)) - sessionData.handleDealingDamage(target, resprojectile) - case None => ; + ResolveProjectileInteraction(projectile, resolution1, target, target.Position).collect { resprojectile => + addShotsLanded(resprojectile.cause.attribution, shots = 1) + sessionData.handleDealingDamage(target, resprojectile) } - case _ => ; + case _ => () } //other victims targets.foreach(elem => { sessionData.validObject(elem.uid, decorator = "SplashHit/other_victims") match { case Some(target: PlanetSideGameObject with FactionAffinity with Vitality) => CheckForHitPositionDiscrepancy(projectile_guid, explosion_pos, target) - ResolveProjectileInteraction(projectile, resolution2, target, explosion_pos) match { - case Some(resprojectile) => - avatarActor ! AvatarActor.UpdateToolDischarge(EquipmentStat(resprojectile.cause.attribution,0,1,0)) - sessionData.handleDealingDamage(target, resprojectile) - case None => ; + ResolveProjectileInteraction(projectile, resolution2, target, explosion_pos).collect { resprojectile => + addShotsLanded(resprojectile.cause.attribution, shots = 1) + sessionData.handleDealingDamage(target, resprojectile) } - case _ => ; + case _ => () } }) //... @@ -413,7 +410,7 @@ private[support] class WeaponAndProjectileOperations( continent.Projectile ! ZoneProjectile.Remove(projectile.GUID) } } - case None => ; + case None => () } } @@ -422,13 +419,12 @@ private[support] class WeaponAndProjectileOperations( sessionData.validObject(victim_guid, decorator = "Lash") match { case Some(target: PlanetSideGameObject with FactionAffinity with Vitality) => CheckForHitPositionDiscrepancy(projectile_guid, hit_pos, target) - ResolveProjectileInteraction(projectile_guid, DamageResolution.Lash, target, hit_pos) match { - case Some(resprojectile) => - avatarActor ! AvatarActor.UpdateToolDischarge(EquipmentStat(resprojectile.cause.attribution,0,1,0)) + ResolveProjectileInteraction(projectile_guid, DamageResolution.Lash, target, hit_pos).foreach { + resprojectile => + addShotsLanded(resprojectile.cause.attribution, shots = 1) sessionData.handleDealingDamage(target, resprojectile) - case None => ; } - case _ => ; + case _ => () } } @@ -524,7 +520,7 @@ private[support] class WeaponAndProjectileOperations( obj match { case turret: FacilityTurret if turret.Definition == GlobalDefinitions.vanu_sentry_turret => turret.Actor ! FacilityTurret.WeaponDischarged() - case _ => ; + case _ => () } } else { log.warn( @@ -532,7 +528,7 @@ private[support] class WeaponAndProjectileOperations( ) } - case _ => ; + case _ => () } } @@ -552,7 +548,7 @@ private[support] class WeaponAndProjectileOperations( case Some(v: Vehicle) => //assert subsystem states v.SubsystemMessages().foreach { sendResponse } - case _ => ; + case _ => () } } if (enabledTools.nonEmpty) { @@ -577,8 +573,9 @@ private[support] class WeaponAndProjectileOperations( avatarActor ! AvatarActor.ConsumeStamina(avatar.stamina) } avatarActor ! AvatarActor.SuspendStaminaRegeneration(3.seconds) + tool.Discharge() prefire += weaponGUID - ongoingShotsFired = ongoingShotsFired + tool.Discharge() + addShotsFired(tool.Definition.ObjectId, tool.AmmoSlot.Chamber) } (o, Some(tool)) } @@ -635,7 +632,7 @@ private[support] class WeaponAndProjectileOperations( continent.GUID(weapon_guid) match { case Some(tool: Tool) => EmptyMagazine(weapon_guid, tool) - case _ => ; + case _ => () } } @@ -719,19 +716,17 @@ private[support] class WeaponAndProjectileOperations( */ def ModifyAmmunitionInMountable(obj: PlanetSideServerObject with Container)(box: AmmoBox, reloadValue: Int): Unit = { ModifyAmmunition(obj)(box, reloadValue) - obj.Find(box) match { - case Some(index) => - continent.VehicleEvents ! VehicleServiceMessage( - s"${obj.Actor}", - VehicleAction.InventoryState( - player.GUID, - box, - obj.GUID, - index, - box.Definition.Packet.DetailedConstructorData(box).get - ) + obj.Find(box).collect { index => + continent.VehicleEvents ! VehicleServiceMessage( + s"${obj.Actor}", + VehicleAction.InventoryState( + player.GUID, + box, + obj.GUID, + index, + box.Definition.Packet.DetailedConstructorData(box).get ) - case None => ; + ) } } @@ -849,7 +844,7 @@ private[support] class WeaponAndProjectileOperations( sessionData.normalItemDrop(player, continent)(previousBox) } AmmoBox.Split(previousBox) match { - case Nil | List(_) => ; //done (the former case is technically not possible) + case Nil | List(_) => () //done (the former case is technically not possible) case _ :: toUpdate => modifyFunc(previousBox, 0) //update to changed capacity value toUpdate.foreach(box => { TaskWorkflow.execute(stowNewFunc(box)) }) @@ -1151,7 +1146,6 @@ private[support] class WeaponAndProjectileOperations( prefire -= itemGuid shooting += itemGuid shootingStart += itemGuid -> System.currentTimeMillis() - ongoingShotsFired = 0 } private def fireStateStartChargeMode(tool: Tool): Unit = { @@ -1220,11 +1214,10 @@ private[support] class WeaponAndProjectileOperations( used by ChangeFireStateMessage_Stop handling */ private def fireStateStopUpdateChargeAndCleanup(tool: Tool): Unit = { - avatarActor ! AvatarActor.UpdateToolDischarge(EquipmentStat(tool.Definition.ObjectId, ongoingShotsFired, 0, 0)) tool.FireMode match { case _: ChargeFireModeDefinition => sendResponse(QuantityUpdateMessage(tool.AmmoSlot.Box.GUID, tool.Magazine)) - case _ => ; + case _ => () } if (tool.Magazine == 0) { FireCycleCleanup(tool) @@ -1365,6 +1358,49 @@ private[support] class WeaponAndProjectileOperations( ) } + private def addShotsFired(weaponId: Int, shots: Int): Unit = { + addShotsToMap(shotsFired, weaponId, shots) + } + + private def addShotsLanded(weaponId: Int, shots: Int): Unit = { + addShotsToMap(shotsLanded, weaponId, shots) + } + + private def addShotsToMap(map: mutable.HashMap[Int, Int], weaponId: Int, shots: Int): Unit = { + map.put( + weaponId, + map.get(weaponId) match { + case Some(previousShots) => previousShots + shots + case None => shots + } + ) + } + + private[support] def reportOngoingShots(reportFunc: (Long, Int, Int, Int) => Unit): Unit = { + reportOngoingShots(player.CharId, reportFunc) + } + + private[support] def reportOngoingShots(avatarId: Long, reportFunc: (Long, Int, Int, Int) => Unit): Unit = { + //only shots that have been reported as fired count + //if somehow shots had reported as landed but never reported as fired, they are ignored + //these are just raw counts; there's only numeric connection between the entries of fired and of landed + shotsFired.foreach { case (weaponId, fired) => + val landed = math.min(shotsLanded.getOrElse(weaponId, 0), fired) + reportFunc(avatarId, weaponId, fired, landed) + } + shotsFired.clear() + shotsLanded.clear() + } + + //noinspection ScalaUnusedSymbol + private[support] def reportOngoingShotsToAvatar(avatarId: Long, weaponId: Int, fired: Int, landed: Int): Unit = { + avatarActor ! AvatarActor.UpdateToolDischarge(EquipmentStat(weaponId, fired, landed, 0, 0)) + } + + private[support] def reportOngoingShotsToDatabase(avatarId: Long, weaponId: Int, fired: Int, landed: Int): Unit = { + ToDatabase.reportToolDischarge(avatarId, EquipmentStat(weaponId, fired, landed, 0, 0)) + } + override protected[session] def stop(): Unit = { if (player != null && player.HasGUID) { (prefire ++ shooting).foreach { guid => @@ -1373,6 +1409,7 @@ private[support] class WeaponAndProjectileOperations( fireStateStopMountedMessages(guid) } projectiles.indices.foreach { projectiles.update(_, None) } + reportOngoingShots(reportOngoingShotsToDatabase) } } } diff --git a/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala b/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala index 702dc6e02..9da4cc00c 100644 --- a/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala +++ b/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala @@ -7,11 +7,14 @@ import akka.actor.{ActorContext, ActorRef, Cancellable, typed} import akka.pattern.ask import akka.util.Timeout import net.psforever.login.WorldSession +import net.psforever.objects.avatar.BattleRank +import net.psforever.objects.avatar.scoring.{CampaignStatistics, ScoreCard, SessionStatistics} import net.psforever.objects.inventory.InventoryItem import net.psforever.objects.serverobject.mount.Seat import net.psforever.objects.serverobject.tube.SpawnTube import net.psforever.objects.sourcing.{PlayerSource, SourceEntry, VehicleSource} import net.psforever.objects.vital.{InGameHistory, ReconstructionActivity, SpawningActivity} +import net.psforever.packet.game.{CampaignStatistic, MailMessage, SessionStatistic} import scala.collection.mutable import scala.concurrent.duration._ @@ -69,6 +72,30 @@ import net.psforever.zones.Zones object ZoningOperations { private final val zoningCountdownMessages: Seq[Int] = Seq(5, 10, 20) + + def reportProgressionSystem(sessionActor: ActorRef): Unit = { + sessionActor ! SessionActor.SendResponse( + MailMessage( + "High Command", + "Progress versus Promotion", + "If you consider yourself as a veteran soldier, despite looking so green, please read this.\n" ++ + "You only have this opportunity while you are battle rank 1." ++ + "\n\n" ++ + "The normal method of rank advancement comes from progress on the battlefield - fighting enemies, helping allies, and capturing facilities. " ++ + "\n\n" ++ + s"You may, however, rapidly promote yourself to at most battle rank ${Config.app.game.promotion.maxBattleRank}. " ++ + "You have access to all of the normal benefits, certification points, implants, etc., of your chosen rank. " ++ + "However, that experience that you have skipped will count as PROMOTION DEBT. " ++ + "You will not advance any further until you earn that experience back through support activity and engaging in facility capture. " ++ + "The amount of experience required and your own effort will determine how long it takes. " ++ + "In addition, you will be ineligible of having your command experience be recognized during this time." ++ + "\n\n" ++ + "If you wish to continue, set your desired battle rank now - use '!progress' followed by a battle rank index. " ++ + "If you accept, but it becomes too much of burden, you may ask to revert to battle rank 2 at any time. " ++ + "Your normal sense of progress will be restored." + ) + ) + } } class ZoningOperations( @@ -258,7 +285,7 @@ class ZoningOperations( obj.GUID, Deployable.Icon(obj.Definition.Item), obj.Position, - obj.Owner.getOrElse(PlanetSideGUID(0)) + obj.OwnerGuid.getOrElse(PlanetSideGUID(0)) ) sendResponse(DeployableObjectsInfoMessage(DeploymentAction.Build, deployInfo)) }) @@ -1657,6 +1684,8 @@ class ZoningOperations( private[support] var reviveTimer: Cancellable = Default.Cancellable private[support] var respawnTimer: Cancellable = Default.Cancellable + private var statisticsPacketFunc: () => Unit = loginAvatarStatisticsFields + /* packets */ def handleReleaseAvatarRequest(pkt: ReleaseAvatarRequestMessage): Unit = { @@ -1803,6 +1832,7 @@ class ZoningOperations( sessionData.persistFunc = UpdatePersistence(from) //tell the old WorldSessionActor to kill itself by using its own subscriptions against itself inZone.AvatarEvents ! AvatarServiceMessage(name, AvatarAction.TeardownConnection()) + spawn.switchAvatarStatisticsFieldToRefreshAfterRespawn() //find and reload previous player ( inZone.Players.find(p => p.name.equals(name)), @@ -2342,6 +2372,7 @@ class ZoningOperations( player.avatar = player.avatar.copy(stamina = avatar.maxStamina) avatarActor ! AvatarActor.RestoreStamina(avatar.maxStamina) avatarActor ! AvatarActor.ResetImplants() + zones.exp.ToDatabase.reportRespawns(tplayer.CharId, ScoreCard.reviveCount(player.avatar.scorecard.CurrentLife)) val obj = Player.Respawn(tplayer) DefinitionUtil.applyDefaultLoadout(obj) obj.death_by = tplayer.death_by @@ -2614,10 +2645,11 @@ class ZoningOperations( LoadZoneAsPlayer(newPlayer, zoneId) } else { avatarActor ! AvatarActor.DeactivateActiveImplants() + val betterSpawnPoint = physSpawnPoint.collect { case o: PlanetSideGameObject with FactionAffinity with InGameHistory => o } interstellarFerry.orElse(continent.GUID(player.VehicleSeated)) match { case Some(vehicle: Vehicle) => // driver or passenger in vehicle using a warp gate, or a droppod - InGameHistory.SpawnReconstructionActivity(vehicle, toZoneNumber, toSpawnPoint) - InGameHistory.SpawnReconstructionActivity(player, toZoneNumber, toSpawnPoint) + InGameHistory.SpawnReconstructionActivity(vehicle, toZoneNumber, betterSpawnPoint) + InGameHistory.SpawnReconstructionActivity(player, toZoneNumber, betterSpawnPoint) LoadZoneInVehicle(vehicle, pos, ori, zoneId) case _ if player.HasGUID => // player is deconstructing self or instant action @@ -2629,13 +2661,13 @@ class ZoningOperations( ) player.Position = pos player.Orientation = ori - InGameHistory.SpawnReconstructionActivity(player, toZoneNumber, toSpawnPoint) + InGameHistory.SpawnReconstructionActivity(player, toZoneNumber, betterSpawnPoint) LoadZoneAsPlayer(player, zoneId) case _ => //player is logging in player.Position = pos player.Orientation = ori - InGameHistory.SpawnReconstructionActivity(player, toZoneNumber, toSpawnPoint) + InGameHistory.SpawnReconstructionActivity(player, toZoneNumber, betterSpawnPoint) LoadZoneAsPlayer(player, zoneId) } } @@ -2803,10 +2835,7 @@ class ZoningOperations( .foreach { case (_, building) => sendResponse(PlanetsideAttributeMessage(building.GUID, 67, 0 /*building.BuildingType == StructureType.Facility*/)) } - (0 to 30).foreach(_ => { - //TODO 30 for a new character only? - sendResponse(AvatarStatisticsMessage(DeathStatistic(0L))) - }) + statisticsPacketFunc() if (tplayer.ExoSuit == ExoSuitType.MAX) { sendResponse(PlanetsideAttributeMessage(guid, 7, tplayer.Capacitor.toLong)) } @@ -2823,7 +2852,7 @@ class ZoningOperations( continent.DeployableList .filter(_.OwnerName.contains(name)) .foreach(obj => { - obj.Owner = guid + obj.OwnerGuid = guid drawDeloyableIcon(obj) }) drawDeloyableIcon = DontRedrawIcons @@ -2831,7 +2860,7 @@ class ZoningOperations( //assert or transfer vehicle ownership continent.GUID(player.avatar.vehicle) match { case Some(vehicle: Vehicle) if vehicle.OwnerName.contains(tplayer.Name) => - vehicle.Owner = guid + vehicle.OwnerGuid = guid continent.VehicleEvents ! VehicleServiceMessage( s"${tplayer.Faction}", VehicleAction.Ownership(guid, vehicle.GUID) @@ -2906,7 +2935,7 @@ class ZoningOperations( val effortBy = nextSpawnPoint .collect { case sp: SpawnTube => (sp, continent.GUID(sp.Owner.GUID)) } .collect { - case (_, Some(v: Vehicle)) => continent.GUID(v.Owner) + case (_, Some(v: Vehicle)) => continent.GUID(v.OwnerGuid) case (sp, Some(_: Building)) => Some(sp) } .collect { case Some(thing: PlanetSideGameObject with FactionAffinity) => Some(SourceEntry(thing)) } @@ -2918,11 +2947,15 @@ class ZoningOperations( SpawningActivity(PlayerSource(player), continent.Number, effortBy) } }) - //ride - } upstreamMessageCount = 0 setAvatar = true + if ( + BattleRank.withExperience(tplayer.avatar.bep).value == 1 && + Config.app.game.promotion.active && + !account.gm) { + ZoningOperations.reportProgressionSystem(context.self) + } } /** @@ -2961,7 +2994,7 @@ class ZoningOperations( obj.GUID, Deployable.Icon(obj.Definition.Item), obj.Position, - obj.Owner.getOrElse(PlanetSideGUID(0)) + obj.OwnerGuid.getOrElse(PlanetSideGUID(0)) ) sendResponse(DeployableObjectsInfoMessage(DeploymentAction.Build, deployInfo)) } @@ -3164,6 +3197,64 @@ class ZoningOperations( sendResponse(mountPointStatusMessage) } + /** + * Populate the Character Info window's statistics page during login. + * Always send campaign (historical, total) statistics. + * Set to refresh the statistics fields after each respawn from now on. + */ + private def loginAvatarStatisticsFields(): Unit = { + avatar.scorecard.KillStatistics.foreach { case (id, stat) => + val campaign = CampaignStatistics(stat) + val elem = StatisticalElement.fromId(id) + sendResponse(AvatarStatisticsMessage( + CampaignStatistic(StatisticalCategory.Destroyed, elem, campaign.tr, campaign.nc, campaign.vs, campaign.ps) + )) + } + //originally the client sent a death statistic update in between each change of statistic categories, about 30 times + sendResponse(AvatarStatisticsMessage(DeathStatistic(ScoreCard.deathCount(avatar.scorecard)))) + statisticsPacketFunc = respawnAvatarStatisticsFields + } + + /** + * Populate the Character Info window's statistics page after each respawn. + * Check whether to send session-related data, or campaign-related data, or both. + */ + private def respawnAvatarStatisticsFields(): Unit = { + avatar + .scorecard + .KillStatistics + .flatMap { case (id, stat) => + val campaign = CampaignStatistics(stat) + val session = SessionStatistics(stat) + (StatisticalElement.fromId(id), campaign.total, campaign, session.total, session) match { + case (elem, 0, _, _, session) => + Seq(SessionStatistic(StatisticalCategory.Destroyed, elem, session.tr, session.nc, session.vs, session.ps)) + case (elem, _, campaign, 0, _) => + Seq(CampaignStatistic(StatisticalCategory.Destroyed, elem, campaign.tr, campaign.nc, campaign.vs, campaign.ps)) + case (elem, _, campaign, _, session) => + Seq( + CampaignStatistic(StatisticalCategory.Destroyed, elem, campaign.tr, campaign.nc, campaign.vs, campaign.ps), + SessionStatistic(StatisticalCategory.Destroyed, elem, session.tr, session.nc, session.vs, session.ps) + ) + } + } + .foreach { statistics => + sendResponse(AvatarStatisticsMessage(statistics)) + } + //originally the client sent a death statistic update in between each change of statistic categories, about 30 times + sendResponse(AvatarStatisticsMessage(DeathStatistic(ScoreCard.deathCount(avatar.scorecard)))) + } + + /** + * Accessible method to switch population of the Character Info window's statistics page + * from whatever it currently js to after each respawn. + * At the time of "login", only campaign (total, historical) deaths are reported for convenience. + * At the time of "respawn", all fields - campaign and session - should be reported if applicable. + */ + def switchAvatarStatisticsFieldToRefreshAfterRespawn(): Unit = { + statisticsPacketFunc = respawnAvatarStatisticsFields + } + /** * Don't extract the award advancement information from a player character upon respawning or zoning. * You only need to perform that population once at login. diff --git a/src/main/scala/net/psforever/actors/zone/ZoneActor.scala b/src/main/scala/net/psforever/actors/zone/ZoneActor.scala index e355b1b71..43ee1ffa4 100644 --- a/src/main/scala/net/psforever/actors/zone/ZoneActor.scala +++ b/src/main/scala/net/psforever/actors/zone/ZoneActor.scala @@ -15,7 +15,7 @@ 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.objects.zones.exp.{ExperienceCalculator, SupportExperienceCalculator} import net.psforever.util.Database._ import net.psforever.persistence @@ -87,6 +87,7 @@ class ZoneActor(context: ActorContext[ZoneActor.Command], zone: Zone) private[this] val log = org.log4s.getLogger private val players: mutable.ListBuffer[Player] = mutable.ListBuffer() private val experience: ActorRef[ExperienceCalculator.Command] = context.spawnAnonymous(ExperienceCalculator(zone)) + private val supportExperience: ActorRef[SupportExperienceCalculator.Command] = context.spawnAnonymous(SupportExperienceCalculator(zone)) zone.actor = context.self zone.init(context.toClassic) @@ -154,7 +155,7 @@ class ZoneActor(context: ActorContext[ZoneActor.Command], zone: Zone) experience ! ExperienceCalculator.RewardThisDeath(entity) case RewardOurSupporters(target, history, kill, bep) => - () + supportExperience ! SupportExperienceCalculator.RewardOurSupporters(target, history, kill, bep) case ZoneMapUpdate() => zone.Buildings @@ -162,7 +163,6 @@ class ZoneActor(context: ActorContext[ZoneActor.Command], zone: Zone) .values .foreach(_.Actor ! BuildingActor.MapUpdate()) } - this } } diff --git a/src/main/scala/net/psforever/login/WorldSession.scala b/src/main/scala/net/psforever/login/WorldSession.scala index 4a03211b4..f49d54d65 100644 --- a/src/main/scala/net/psforever/login/WorldSession.scala +++ b/src/main/scala/net/psforever/login/WorldSession.scala @@ -942,8 +942,9 @@ object WorldSession { result: Boolean ): Unit = { if (result) { - player.Zone.GUID(guid).collect { - case term: Terminal => player.LogActivity(TerminalUsedActivity(AmenitySource(term), transaction)) + player.Zone.GUID(guid).collect { case term: Terminal => + player.LogActivity(TerminalUsedActivity(AmenitySource(term), transaction)) + player.ContributionFrom(term) } } player.Zone.AvatarEvents ! AvatarServiceMessage( diff --git a/src/main/scala/net/psforever/objects/BoomerDeployable.scala b/src/main/scala/net/psforever/objects/BoomerDeployable.scala index 0490c2303..33fb76008 100644 --- a/src/main/scala/net/psforever/objects/BoomerDeployable.scala +++ b/src/main/scala/net/psforever/objects/BoomerDeployable.scala @@ -72,7 +72,9 @@ class BoomerDeployableControl(mine: BoomerDeployable) override def loseOwnership(faction: PlanetSideEmpire.Value): Unit = { super.loseOwnership(PlanetSideEmpire.NEUTRAL) - mine.OwnerName = None + val guid = mine.OwnerGuid + mine.AssignOwnership(None) + mine.OwnerGuid = guid } override def gainOwnership(player: Player): Unit = { diff --git a/src/main/scala/net/psforever/objects/Deployables.scala b/src/main/scala/net/psforever/objects/Deployables.scala index 085eaef4b..826b38761 100644 --- a/src/main/scala/net/psforever/objects/Deployables.scala +++ b/src/main/scala/net/psforever/objects/Deployables.scala @@ -89,8 +89,7 @@ object Deployables { .foreach { p => p.Actor ! Player.LoseDeployable(target) } - target.Owner = None - target.OwnerName = None + target.AssignOwnership(None) } events ! LocalServiceMessage( s"${target.Faction}", @@ -119,7 +118,7 @@ object Deployables { .collect { case Some(obj: Deployable) => obj.Actor ! Deployable.Ownership(None) - obj.Owner = None //fast-forward the effect + obj.OwnerGuid = None //fast-forward the effect obj } } diff --git a/src/main/scala/net/psforever/objects/ExplosiveDeployable.scala b/src/main/scala/net/psforever/objects/ExplosiveDeployable.scala index 8388008b6..201d39d8d 100644 --- a/src/main/scala/net/psforever/objects/ExplosiveDeployable.scala +++ b/src/main/scala/net/psforever/objects/ExplosiveDeployable.scala @@ -348,7 +348,7 @@ object MineDeployableControl { jumping = false, ExoSuitDefinition.Select(exosuit, faction), bep = 0, - kills = Nil, + progress = PlayerSource.Nobody.progress, UniquePlayer(charId, name, CharacterSex.Male, mine.Faction) ) case None => diff --git a/src/main/scala/net/psforever/objects/GlobalDefinitions.scala b/src/main/scala/net/psforever/objects/GlobalDefinitions.scala index a5910e792..77cb44d27 100644 --- a/src/main/scala/net/psforever/objects/GlobalDefinitions.scala +++ b/src/main/scala/net/psforever/objects/GlobalDefinitions.scala @@ -9847,6 +9847,7 @@ object GlobalDefinitions { resource_silo.Damageable = false resource_silo.Repairable = false resource_silo.MaxNtuCapacitor = 1000 + resource_silo.ChargeTime = 105.seconds //from 0-100% in roughly 105s on live (~20%-100% https://youtu.be/veOWToR2nSk?t=1402) capture_terminal.Name = "capture_terminal" capture_terminal.Damageable = false @@ -9856,7 +9857,7 @@ object GlobalDefinitions { secondary_capture.Name = "secondary_capture" secondary_capture.Damageable = false secondary_capture.Repairable = false - secondary_capture.FacilityHackTime = 1.nanosecond + secondary_capture.FacilityHackTime = 1.millisecond vanu_control_console.Name = "vanu_control_console" vanu_control_console.Damageable = false diff --git a/src/main/scala/net/psforever/objects/OwnableByPlayer.scala b/src/main/scala/net/psforever/objects/OwnableByPlayer.scala index 5892ef75e..a83c908e4 100644 --- a/src/main/scala/net/psforever/objects/OwnableByPlayer.scala +++ b/src/main/scala/net/psforever/objects/OwnableByPlayer.scala @@ -1,45 +1,33 @@ // Copyright (c) 2019 PSForever package net.psforever.objects +import net.psforever.objects.sourcing.{PlayerSource, UniquePlayer} import net.psforever.types.PlanetSideGUID trait OwnableByPlayer { - private var owner: Option[PlanetSideGUID] = None - private var ownerName: Option[String] = None - private var originalOwnerName: Option[String] = None + private var owner: Option[UniquePlayer] = None + private var ownerGuid: Option[PlanetSideGUID] = None + private var originalOwnerName: Option[String] = None - def Owner: Option[PlanetSideGUID] = owner + def Owners: Option[UniquePlayer] = owner - def Owner_=(owner: PlanetSideGUID): Option[PlanetSideGUID] = Owner_=(Some(owner)) + def OwnerGuid: Option[PlanetSideGUID] = ownerGuid - def Owner_=(owner: Player): Option[PlanetSideGUID] = Owner_=(Some(owner.GUID)) + def OwnerGuid_=(owner: PlanetSideGUID): Option[PlanetSideGUID] = OwnerGuid_=(Some(owner)) - def Owner_=(owner: Option[PlanetSideGUID]): Option[PlanetSideGUID] = { + def OwnerGuid_=(owner: Player): Option[PlanetSideGUID] = OwnerGuid_=(Some(owner.GUID)) + + def OwnerGuid_=(owner: Option[PlanetSideGUID]): Option[PlanetSideGUID] = { owner match { case Some(_) => - this.owner = owner + ownerGuid = owner case None => - this.owner = None + ownerGuid = None } - Owner + OwnerGuid } - def OwnerName: Option[String] = ownerName - - def OwnerName_=(owner: String): Option[String] = OwnerName_=(Some(owner)) - - def OwnerName_=(owner: Player): Option[String] = OwnerName_=(Some(owner.Name)) - - def OwnerName_=(owner: Option[String]): Option[String] = { - owner match { - case Some(_) => - ownerName = owner - originalOwnerName = originalOwnerName.orElse(owner) - case None => - ownerName = None - } - OwnerName - } + def OwnerName: Option[String] = owner.map { _.name } def OriginalOwnerName: Option[String] = originalOwnerName @@ -56,14 +44,30 @@ trait OwnableByPlayer { * @return na */ def AssignOwnership(playerOpt: Option[Player]): OwnableByPlayer = { - playerOpt match { - case Some(player) => - Owner = player - OwnerName = player - case None => - Owner = None - OwnerName = None + (originalOwnerName, playerOpt) match { + case (None, Some(player)) => + owner = Some(PlayerSource(player).unique) + originalOwnerName = originalOwnerName.orElse { Some(player.Name) } + OwnerGuid = player + case (_, Some(player)) => + owner = Some(PlayerSource(player).unique) + OwnerGuid = player + case (_, None) => + owner = None + OwnerGuid = None } this } + + /** + * na + * @param ownable na + * @return na + */ + def AssignOwnership(ownable: OwnableByPlayer): OwnableByPlayer = { + owner = ownable.owner + originalOwnerName = originalOwnerName.orElse { ownable.originalOwnerName } + OwnerGuid = ownable.OwnerGuid + this + } } diff --git a/src/main/scala/net/psforever/objects/Player.scala b/src/main/scala/net/psforever/objects/Player.scala index 343092dad..9b7f55181 100644 --- a/src/main/scala/net/psforever/objects/Player.scala +++ b/src/main/scala/net/psforever/objects/Player.scala @@ -13,7 +13,7 @@ import net.psforever.objects.serverobject.aura.AuraContainer import net.psforever.objects.serverobject.environment.InteractWithEnvironment import net.psforever.objects.serverobject.mount.MountableEntity import net.psforever.objects.vital.resistance.ResistanceProfile -import net.psforever.objects.vital.Vitality +import net.psforever.objects.vital.{HealFromEquipment, InGameActivity, RepairFromEquipment, Vitality} import net.psforever.objects.vital.damage.DamageProfile import net.psforever.objects.vital.interaction.DamageInteraction import net.psforever.objects.vital.resolution.DamageResistanceModel @@ -110,6 +110,7 @@ class Player(var avatar: Avatar) Health = Definition.DefaultHealth Armor = MaxArmor Capacitor = 0 + avatar.scorecard.respawn() released = false } isAlive @@ -124,13 +125,16 @@ class Player(var avatar: Avatar) def Revive: Boolean = { Destroyed = false Health = Definition.DefaultHealth + avatar.scorecard.revive() released = false true } def Release: Boolean = { - released = true - backpack = !isAlive + if (!released) { + released = true + backpack = !isAlive + } true } @@ -422,12 +426,13 @@ class Player(var avatar: Avatar) def UsingSpecial_=(state: SpecialExoSuitDefinition.Mode.Value): SpecialExoSuitDefinition.Mode.Value = usingSpecial(state) + //noinspection ScalaUnusedSymbol private def DefaultUsingSpecial(state: SpecialExoSuitDefinition.Mode.Value): SpecialExoSuitDefinition.Mode.Value = SpecialExoSuitDefinition.Mode.Normal private def UsingAnchorsOrOverdrive( - state: SpecialExoSuitDefinition.Mode.Value - ): SpecialExoSuitDefinition.Mode.Value = { + state: SpecialExoSuitDefinition.Mode.Value + ): SpecialExoSuitDefinition.Mode.Value = { import SpecialExoSuitDefinition.Mode._ val curr = UsingSpecial val next = if (curr == Normal) { @@ -532,11 +537,14 @@ class Player(var avatar: Avatar) def Carrying: Option[SpecialCarry] = carrying + //noinspection ScalaUnusedSymbol def Carrying_=(item: SpecialCarry): Option[SpecialCarry] = { - Carrying + Carrying_=(Some(item)) } + //noinspection ScalaUnusedSymbol def Carrying_=(item: Option[SpecialCarry]): Option[SpecialCarry] = { + carrying = item Carrying } @@ -553,6 +561,14 @@ class Player(var avatar: Avatar) def DamageModel: DamageResistanceModel = exosuit.asInstanceOf[DamageResistanceModel] + override def GetContributionDuringPeriod(list: List[InGameActivity], duration: Long): List[InGameActivity] = { + val earliestEndTime = System.currentTimeMillis() - duration + History.collect { + case heal: HealFromEquipment if heal.amount > 0 && heal.time > earliestEndTime => heal + case repair: RepairFromEquipment if repair.amount > 0 && repair.time > earliestEndTime => repair + } + } + def canEqual(other: Any): Boolean = other.isInstanceOf[Player] override def equals(other: Any): Boolean = @@ -614,12 +630,14 @@ object Player { if (player.Release) { val obj = new Player(player.avatar) obj.Continent = player.Continent + obj.avatar.scorecard.respawn() obj } else { player } } + //noinspection ScalaUnusedSymbol def neverRestrict(player: Player, slot: Int): Boolean = { false } diff --git a/src/main/scala/net/psforever/objects/Players.scala b/src/main/scala/net/psforever/objects/Players.scala index 63f623af7..9a0993ef1 100644 --- a/src/main/scala/net/psforever/objects/Players.scala +++ b/src/main/scala/net/psforever/objects/Players.scala @@ -10,6 +10,8 @@ import net.psforever.objects.equipment.EquipmentSlot import net.psforever.objects.guid.{GUIDTask, TaskWorkflow} import net.psforever.objects.inventory.InventoryItem import net.psforever.objects.loadouts.InfantryLoadout +import net.psforever.objects.sourcing.PlayerSource +import net.psforever.objects.vital.RevivingActivity import net.psforever.objects.zones.Zone import net.psforever.packet.game._ import net.psforever.types.{ChatMessageType, ExoSuitType, Vector3} @@ -64,7 +66,7 @@ object Players { val name = target.Name val medicName = medic.Name log.info(s"$medicName had revived $name") - //target.History(PlayerRespawn(PlayerSource(target), target.Zone, target.Position, Some(PlayerSource(medic)))) + target.LogActivity(RevivingActivity(PlayerSource(target), PlayerSource(medic), target.MaxHealth, item.Definition)) val magazine = item.Discharge(Some(25)) target.Zone.AvatarEvents ! AvatarServiceMessage( medicName, diff --git a/src/main/scala/net/psforever/objects/ShieldGeneratorDeployable.scala b/src/main/scala/net/psforever/objects/ShieldGeneratorDeployable.scala index 18dca79df..afce1d3fc 100644 --- a/src/main/scala/net/psforever/objects/ShieldGeneratorDeployable.scala +++ b/src/main/scala/net/psforever/objects/ShieldGeneratorDeployable.scala @@ -3,7 +3,7 @@ package net.psforever.objects import akka.actor.{Actor, ActorContext, Props} import net.psforever.objects.ce.{Deployable, DeployableBehavior, DeployableCategory} -import net.psforever.objects.definition.DeployableDefinition +import net.psforever.objects.definition.{DeployableDefinition, WithShields} import net.psforever.objects.definition.converter.ShieldGeneratorConverter import net.psforever.objects.equipment.{JammableBehavior, JammableUnit} import net.psforever.objects.serverobject.damage.Damageable.Target @@ -22,7 +22,8 @@ class ShieldGeneratorDeployable(cdef: ShieldGeneratorDefinition) with Hackable with JammableUnit -class ShieldGeneratorDefinition extends DeployableDefinition(240) { +class ShieldGeneratorDefinition extends DeployableDefinition(240) + with WithShields { Packet = new ShieldGeneratorConverter DeployCategory = DeployableCategory.ShieldGenerators diff --git a/src/main/scala/net/psforever/objects/SpecialEmp.scala b/src/main/scala/net/psforever/objects/SpecialEmp.scala index 32a59853f..17da3b464 100644 --- a/src/main/scala/net/psforever/objects/SpecialEmp.scala +++ b/src/main/scala/net/psforever/objects/SpecialEmp.scala @@ -112,11 +112,10 @@ object SpecialEmp { faction: PlanetSideEmpire.Value ): (PlanetSideGameObject, PlanetSideGameObject, Float) => Boolean = { distanceCheck(new PlanetSideServerObject with OwnableByPlayer { - Owner = Some(owner.GUID) - OwnerName = owner match { - case p: Player => p.Name - case o: OwnableByPlayer => o.OwnerName.getOrElse("") - case _ => "" + owner match { + case p: Player => AssignOwnership(p) + case o: OwnableByPlayer => AssignOwnership(o) + case _ => OwnerGuid_=(Some(owner.GUID)) } Position = position def Faction = faction diff --git a/src/main/scala/net/psforever/objects/TelepadDeployable.scala b/src/main/scala/net/psforever/objects/TelepadDeployable.scala index 1a00dba95..a0cfa954a 100644 --- a/src/main/scala/net/psforever/objects/TelepadDeployable.scala +++ b/src/main/scala/net/psforever/objects/TelepadDeployable.scala @@ -103,8 +103,7 @@ class TelepadDeployableControl(tpad: TelepadDeployable) override def startOwnerlessDecay(): Unit = { //telepads do not decay when they become ownerless //telepad decay is tied to their lifecycle with routers - tpad.Owner = None - tpad.OwnerName = None + tpad.AssignOwnership(None) } override def finalizeDeployable(callback: ActorRef): Unit = { diff --git a/src/main/scala/net/psforever/objects/Tool.scala b/src/main/scala/net/psforever/objects/Tool.scala index 17ccd3ce5..0c8b0d286 100644 --- a/src/main/scala/net/psforever/objects/Tool.scala +++ b/src/main/scala/net/psforever/objects/Tool.scala @@ -99,7 +99,7 @@ class Tool(private val toolDef: ToolDefinition) } def Discharge(rounds: Option[Int] = None): Int = { - lastDischarge = System.nanoTime() + lastDischarge = System.currentTimeMillis() Magazine = FireMode.Discharge(this, rounds) } diff --git a/src/main/scala/net/psforever/objects/Vehicle.scala b/src/main/scala/net/psforever/objects/Vehicle.scala index b2e09fcf9..962e19cff 100644 --- a/src/main/scala/net/psforever/objects/Vehicle.scala +++ b/src/main/scala/net/psforever/objects/Vehicle.scala @@ -95,6 +95,7 @@ class Vehicle(private val vehicleDef: VehicleDefinition) interaction(new InteractWithRadiationCloudsSeatedInVehicle(obj = this, range = 20)) private var faction: PlanetSideEmpire.Value = PlanetSideEmpire.NEUTRAL + private var previousFaction: PlanetSideEmpire.Value = PlanetSideEmpire.NEUTRAL private var shields: Int = 0 private var decal: Int = 0 private var trunkAccess: Option[PlanetSideGUID] = None @@ -128,14 +129,18 @@ class Vehicle(private val vehicleDef: VehicleDefinition) } def Faction: PlanetSideEmpire.Value = { - this.faction - } - - override def Faction_=(faction: PlanetSideEmpire.Value): PlanetSideEmpire.Value = { - this.faction = faction faction } + override def Faction_=(toFaction: PlanetSideEmpire.Value): PlanetSideEmpire.Value = { + //TODO in the future, this may create an issue when the vehicle is originally or is hacked from Black Ops + previousFaction = faction + faction = toFaction + toFaction + } + + def PreviousFaction: PlanetSideEmpire.Value = previousFaction + /** How long it takes to jack the vehicle in seconds, based on the hacker's certification level */ def JackingDuration: Array[Int] = Definition.JackingDuration @@ -267,30 +272,7 @@ class Vehicle(private val vehicleDef: VehicleDefinition) } def SeatPermissionGroup(seatNumber: Int): Option[AccessPermissionGroup.Value] = { - if (seatNumber == 0) { //valid in almost all cases - Some(AccessPermissionGroup.Driver) - } else { - Seat(seatNumber) match { - case Some(_) => - Definition.controlledWeapons().get(seatNumber) match { - case Some(_) => - Some(AccessPermissionGroup.Gunner) - case None => - Some(AccessPermissionGroup.Passenger) - } - case None => - CargoHold(seatNumber) match { - case Some(_) => - Some(AccessPermissionGroup.Passenger) //TODO confirm this - case None => - if (seatNumber >= trunk.Offset && seatNumber < trunk.Offset + trunk.TotalCapacity) { - Some(AccessPermissionGroup.Trunk) - } else { - None - } - } - } - } + Vehicles.SeatPermissionGroup(this.Definition, seatNumber) } def Utilities: Map[Int, Utility] = utilities @@ -358,9 +340,9 @@ class Vehicle(private val vehicleDef: VehicleDefinition) } } - override def DeployTime = Definition.DeployTime + override def DeployTime: Int = Definition.DeployTime - override def UndeployTime = Definition.UndeployTime + override def UndeployTime: Int = Definition.UndeployTime def Inventory: GridInventory = trunk @@ -476,7 +458,7 @@ class Vehicle(private val vehicleDef: VehicleDefinition) if (trunkAccess.isEmpty || trunkAccess.contains(player.GUID)) { groupPermissions(3) match { case VehicleLockState.Locked => //only the owner - Owner.isEmpty || (Owner.isDefined && player.GUID == Owner.get) + OwnerGuid.isEmpty || (OwnerGuid.isDefined && player.GUID == OwnerGuid.get) case VehicleLockState.Group => //anyone in the owner's squad or platoon faction == player.Faction //TODO this is not correct case VehicleLockState.Empire => //anyone of the owner's faction @@ -518,7 +500,7 @@ class Vehicle(private val vehicleDef: VehicleDefinition) def PreviousGatingManifest(): Option[VehicleManifest] = previousVehicleGatingManifest - def DamageModel = Definition.asInstanceOf[DamageResistanceModel] + def DamageModel: DamageResistanceModel = Definition.asInstanceOf[DamageResistanceModel] override def BailProtection_=(protect: Boolean): Boolean = { !Definition.CanFly && super.BailProtection_=(protect) @@ -681,6 +663,6 @@ object Vehicle { */ def toString(obj: Vehicle): String = { val occupancy = obj.Seats.values.count(seat => seat.isOccupied) - s"${obj.Definition.Name}, owned by ${obj.Owner}: (${obj.Health}/${obj.MaxHealth})(${obj.Shields}/${obj.MaxShields}) ($occupancy)" + s"${obj.Definition.Name}, owned by ${obj.OwnerGuid}: (${obj.Health}/${obj.MaxHealth})(${obj.Shields}/${obj.MaxShields}) ($occupancy)" } } diff --git a/src/main/scala/net/psforever/objects/Vehicles.scala b/src/main/scala/net/psforever/objects/Vehicles.scala index 22ea1221e..ed661d16a 100644 --- a/src/main/scala/net/psforever/objects/Vehicles.scala +++ b/src/main/scala/net/psforever/objects/Vehicles.scala @@ -2,6 +2,7 @@ package net.psforever.objects import net.psforever.objects.ce.TelepadLike +import net.psforever.objects.definition.VehicleDefinition import net.psforever.objects.serverobject.CommonMessages import net.psforever.objects.serverobject.deploy.Deployment import net.psforever.objects.serverobject.resourcesilo.ResourceSilo @@ -61,7 +62,7 @@ object Vehicles { * `None`, otherwise */ def Disown(guid: PlanetSideGUID, vehicle: Vehicle): Option[Vehicle] = - vehicle.Zone.GUID(vehicle.Owner) match { + vehicle.Zone.GUID(vehicle.OwnerGuid) match { case Some(player: Player) => if (player.avatar.vehicle.contains(guid)) { player.avatar.vehicle = None @@ -127,7 +128,7 @@ object Vehicles { */ def Disown(player: Player, vehicle: Vehicle): Option[Vehicle] = { val pguid = player.GUID - if (vehicle.Owner.contains(pguid)) { + if (vehicle.OwnerGuid.contains(pguid)) { vehicle.AssignOwnership(None) //vehicle.Zone.VehicleEvents ! VehicleServiceMessage(player.Name, VehicleAction.Ownership(pguid, PlanetSideGUID(0))) val vguid = vehicle.GUID @@ -236,16 +237,14 @@ object Vehicles { val zone = target.Zone // Forcefully dismount any cargo target.CargoHolds.foreach { case (_, cargoHold) => - cargoHold.occupant match { - case Some(cargo: Vehicle) => - cargo.Actor ! CargoBehavior.StartCargoDismounting(bailed = false) - case None => ; + cargoHold.occupant.collect { + cargo: Vehicle => cargo.Actor ! CargoBehavior.StartCargoDismounting(bailed = false) } } // Forcefully dismount all seated occupants from the vehicle target.Seats.values.foreach(seat => { - seat.occupant match { - case Some(tplayer: Player) => + seat.occupant.collect { + tplayer: Player => seat.unmount(tplayer) tplayer.VehicleSeated = None if (tplayer.HasGUID) { @@ -254,7 +253,6 @@ object Vehicles { VehicleAction.KickPassenger(tplayer.GUID, 4, unk2 = false, target.GUID) ) } - case _ => ; } }) // If the vehicle can fly and is flying deconstruct it, and well played to whomever managed to hack a plane in mid air. I'm impressed. @@ -263,25 +261,17 @@ object Vehicles { target.Actor ! Vehicle.Deconstruct() } else { // Otherwise handle ownership transfer as normal // Remove ownership of our current vehicle, if we have one - hacker.avatar.vehicle match { - case Some(guid: PlanetSideGUID) => - zone.GUID(guid) match { - case Some(vehicle: Vehicle) => - Vehicles.Disown(hacker, vehicle) - case _ => ; - } - case _ => ; - } - target.Owner match { - case Some(previousOwnerGuid: PlanetSideGUID) => - // Remove ownership of the vehicle from the previous player - zone.GUID(previousOwnerGuid) match { - case Some(tplayer: Player) => - Vehicles.Disown(tplayer, target) - case _ => ; // Vehicle already has no owner - } - case _ => ; - } + hacker.avatar.vehicle + .flatMap { guid => zone.GUID(guid) } + .collect { case vehicle: Vehicle => + Vehicles.Disown(hacker, vehicle) + } + // Remove ownership of the vehicle from the previous player + target.OwnerGuid + .flatMap { guid => zone.GUID(guid) } + .collect { case tplayer: Player => + Vehicles.Disown(tplayer, target) + } // Now take ownership of the jacked vehicle target.Actor ! CommonMessages.Hack(hacker, target) target.Faction = hacker.Faction @@ -301,16 +291,15 @@ object Vehicles { // If AMS is deployed, swap it to the new faction target.Definition match { case GlobalDefinitions.router => - target.Utility(UtilityType.internal_router_telepad_deployable) match { - case Some(util: Utility.InternalTelepad) => + target.Utility(UtilityType.internal_router_telepad_deployable).collect { + case util: Utility.InternalTelepad => //"power cycle" util.Actor ! TelepadLike.Deactivate(util) util.Actor ! TelepadLike.Activate(util) - case _ => ; } case GlobalDefinitions.ams if target.DeploymentState == DriveState.Deployed => zone.VehicleEvents ! VehicleServiceMessage.AMSDeploymentChange(zone) - case _ => ; + case _ => () } } @@ -411,6 +400,7 @@ object Vehicles { * * @param vehicle the vehicle */ + //noinspection ScalaUnusedSymbol def BeforeUnloadVehicle(vehicle: Vehicle, zone: Zone): Unit = { vehicle.Definition match { case GlobalDefinitions.ams => @@ -419,7 +409,7 @@ object Vehicles { vehicle.Actor ! Deployment.TryUndeploy(DriveState.Undeploying) case GlobalDefinitions.router => vehicle.Actor ! Deployment.TryUndeploy(DriveState.Undeploying) - case _ => ; + case _ => () } } @@ -439,4 +429,49 @@ object Vehicles { val turnAway = if (offset.x >= 0) -90f else 90f (obj.Position + offset, (shuttleAngle + turnAway) % 360f) } + + /** + * Based on a mounting index, for a certain mount, to what mounting group does this seat belong? + * @param vehicle the vehicle + * @param seatNumber specific seat index + * @return the seat group designation + */ + def SeatPermissionGroup(vehicle: Vehicle, seatNumber: Int): Option[AccessPermissionGroup.Value] = { + SeatPermissionGroup(vehicle.Definition, seatNumber) + } + + /** + * Based on a mounting index, for a certain mount, to what mounting group does this seat belong? + * @param definition global vehicle specification + * @param seatNumber specific seat index + * @return the seat group designation + */ + def SeatPermissionGroup(definition: VehicleDefinition, seatNumber: Int): Option[AccessPermissionGroup.Value] = { + if (seatNumber == 0) { //valid in almost all cases + Some(AccessPermissionGroup.Driver) + } else { + definition.Seats + .get(seatNumber) + .map { _ => + definition.controlledWeapons() + .get(seatNumber) + .map { _ => AccessPermissionGroup.Gunner } + .getOrElse { AccessPermissionGroup.Passenger } + } + .orElse { + definition.Cargo + .get(seatNumber) + .map { _ => AccessPermissionGroup.Passenger } + .orElse { + val offset = definition.TrunkOffset + val size = definition.TrunkSize + if (seatNumber >= offset && seatNumber < offset + size.Width * size.Height) { + Some(AccessPermissionGroup.Trunk) + } else { + None + } + } + } + } + } } diff --git a/src/main/scala/net/psforever/objects/avatar/PlayerControl.scala b/src/main/scala/net/psforever/objects/avatar/PlayerControl.scala index bb2dfc6bf..c76bab3e9 100644 --- a/src/main/scala/net/psforever/objects/avatar/PlayerControl.scala +++ b/src/main/scala/net/psforever/objects/avatar/PlayerControl.scala @@ -33,7 +33,7 @@ import net.psforever.objects.locker.LockerContainerControl import net.psforever.objects.serverobject.environment._ import net.psforever.objects.serverobject.repair.Repairable import net.psforever.objects.serverobject.shuttle.OrbitalShuttlePad -import net.psforever.objects.sourcing.PlayerSource +import net.psforever.objects.sourcing.{AmenitySource, PlayerSource} import net.psforever.objects.vital.collision.CollisionReason import net.psforever.objects.vital.environment.EnvironmentReason import net.psforever.objects.vital.etc.{PainboxReason, SuicideReason} @@ -356,6 +356,12 @@ class PlayerControl(player: Player, avatarActor: typed.ActorRef[AvatarActor.Comm } case Terminal.TerminalMessage(_, msg, order) => + lazy val terminalUsedAction = { + player.Zone.GUID(msg.terminal_guid).collect { + case t: Terminal => + player.LogActivity(TerminalUsedActivity(AmenitySource(t), msg.transaction_type)) + } + } order match { case Terminal.BuyExosuit(exosuit, subtype) => val result = setExoSuit(exosuit, subtype) @@ -366,6 +372,7 @@ class PlayerControl(player: Player, avatarActor: typed.ActorRef[AvatarActor.Comm player.Name, AvatarAction.TerminalOrderResult(msg.terminal_guid, msg.transaction_type, result) ) + terminalUsedAction case Terminal.InfantryLoadout(exosuit, subtype, holsters, inventory) => log.info(s"${player.Name} wants to change equipment loadout to their option #${msg.unk1 + 1}") @@ -508,7 +515,9 @@ class PlayerControl(player: Player, avatarActor: typed.ActorRef[AvatarActor.Comm player.Name, AvatarAction.TerminalOrderResult(msg.terminal_guid, msg.transaction_type, result=true) ) - case _ => assert(assertion=false, msg.toString) + terminalUsedAction + case _ => + assert(assertion=false, msg.toString) } case Zone.Ground.ItemOnGround(item, _, _) => diff --git a/src/main/scala/net/psforever/objects/avatar/scoring/EquipmentStat.scala b/src/main/scala/net/psforever/objects/avatar/scoring/EquipmentStat.scala index 15c5c07db..1c136ba47 100644 --- a/src/main/scala/net/psforever/objects/avatar/scoring/EquipmentStat.scala +++ b/src/main/scala/net/psforever/objects/avatar/scoring/EquipmentStat.scala @@ -1,8 +1,8 @@ // Copyright (c) 2023 PSForever package net.psforever.objects.avatar.scoring -final case class EquipmentStat(objectId: Int, shotsFired: Int, shotsLanded: Int, kills: Int) +final case class EquipmentStat(objectId: Int, shotsFired: Int, shotsLanded: Int, kills: Int, assists: Int) object EquipmentStat { - def apply(objectId: Int): EquipmentStat = EquipmentStat(objectId, 0, 1, 0) + def apply(objectId: Int): EquipmentStat = EquipmentStat(objectId, 0, 1, 0, 0) } diff --git a/src/main/scala/net/psforever/objects/avatar/scoring/KDAStat.scala b/src/main/scala/net/psforever/objects/avatar/scoring/KDAStat.scala index cf4117bfd..fffa0b7d9 100644 --- a/src/main/scala/net/psforever/objects/avatar/scoring/KDAStat.scala +++ b/src/main/scala/net/psforever/objects/avatar/scoring/KDAStat.scala @@ -3,6 +3,7 @@ package net.psforever.objects.avatar.scoring import net.psforever.objects.sourcing.PlayerSource import net.psforever.objects.vital.interaction.DamageResult +import net.psforever.objects.zones.exp.EquipmentUseContextWrapper import org.joda.time.LocalDateTime trait KDAStat { @@ -10,10 +11,29 @@ trait KDAStat { val time: LocalDateTime = LocalDateTime.now() } -final case class Kill(victim: PlayerSource, info: DamageResult, experienceEarned: Long) extends KDAStat +final case class Kill( + victim: PlayerSource, + info: DamageResult, + experienceEarned: Long + ) extends KDAStat -final case class Assist(victim: PlayerSource, weapons: Seq[Int], damageInflictedPercentage: Float, experienceEarned: Long) extends KDAStat +final case class Assist( + victim: PlayerSource, + weapons: Seq[EquipmentUseContextWrapper], + damageInflictedPercentage: Float, + experienceEarned: Long + ) extends KDAStat -final case class Death(assailant: Seq[PlayerSource], timeAlive: Long, bep: Long) extends KDAStat { +final case class Death( + assailant: Seq[PlayerSource], + timeAlive: Long, + bep: Long + ) extends KDAStat { def experienceEarned: Long = 0 } + +final case class SupportActivity( + target: PlayerSource, + weapons: Seq[EquipmentUseContextWrapper], + experienceEarned: Long + ) extends KDAStat diff --git a/src/main/scala/net/psforever/objects/avatar/scoring/Life.scala b/src/main/scala/net/psforever/objects/avatar/scoring/Life.scala index 105f72a68..527332178 100644 --- a/src/main/scala/net/psforever/objects/avatar/scoring/Life.scala +++ b/src/main/scala/net/psforever/objects/avatar/scoring/Life.scala @@ -5,11 +5,15 @@ final case class Life( kills: Seq[Kill], assists: Seq[Assist], death: Option[Death], - equipmentStats: Seq[EquipmentStat] + equipmentStats: Seq[EquipmentStat], + supportExperience: Long, + prior: Option[Life] ) object Life { - def apply(): Life = Life(Nil, Nil, None, Nil) + def apply(): Life = Life(Nil, Nil, None, Nil, 0, None) + + def revive(prior: Life): Life = Life(Nil, Nil, None, Nil, 0, Some(prior)) def bep(life: Life): Long = { life.kills.foldLeft(0L)(_ + _.experienceEarned) + life.assists.foldLeft(0L)(_ + _.experienceEarned) diff --git a/src/main/scala/net/psforever/objects/avatar/scoring/ScoreCard.scala b/src/main/scala/net/psforever/objects/avatar/scoring/ScoreCard.scala index 43db48efe..40f97f275 100644 --- a/src/main/scala/net/psforever/objects/avatar/scoring/ScoreCard.scala +++ b/src/main/scala/net/psforever/objects/avatar/scoring/ScoreCard.scala @@ -3,7 +3,7 @@ package net.psforever.objects.avatar.scoring import net.psforever.objects.GlobalDefinitions import net.psforever.objects.sourcing.{PlayerSource, SourceEntry, VehicleSource} -import net.psforever.types.{PlanetSideEmpire, StatisticalCategory} +import net.psforever.types.{PlanetSideEmpire, StatisticalCategory, StatisticalElement} import scala.annotation.tailrec import scala.collection.mutable @@ -18,43 +18,97 @@ class ScoreCard() { def Lives: Seq[Life] = lives + def AllLives: Seq[Life] = curr +: lives + + def Kills: Seq[Kill] = lives.flatMap { _.kills } ++ curr.kills + def KillStatistics: Map[Int, Statistic] = killStatistics.toMap def AssistStatistics: Map[Int, Statistic] = assistStatistics.toMap - def rate(msg: Any): Unit = { + def revive(): Unit = { + curr = Life.revive(curr) + } + + def respawn(): Unit = { + val death = curr + curr = Life() + lives = death +: lives + } + + def initStatisticForKill(targetId: Int, victimFaction: PlanetSideEmpire.Value): Statistic = { + ScoreCard.initStatisticsFor(killStatistics, targetId, victimFaction) + } + + def rate(msg: Any): Seq[(Int, Statistic)] = { msg match { case e: EquipmentStat => curr = ScoreCard.updateEquipmentStat(curr, e) + Nil case k: Kill => curr = curr.copy(kills = k +: curr.kills) - curr = ScoreCard.updateEquipmentStat(curr, EquipmentStat(k.info.interaction.cause.attribution, 0, 0, 1)) - ScoreCard.updateStatisticsFor(killStatistics, k.info.interaction.cause.attribution, k.victim.Faction) + //TODO may need to expand these to include other fields later + curr = ScoreCard.updateEquipmentStat(curr, EquipmentStat(k.info.interaction.cause.attribution, 0, 0, 1, 0)) + val wid = StatisticalElement.relatedElement(k.victim.ExoSuit).value + Seq((wid, ScoreCard.updateStatisticsFor(killStatistics, wid, k.victim.Faction))) case a: Assist => curr = curr.copy(assists = a +: curr.assists) val faction = a.victim.Faction - a.weapons.foreach { wid => - ScoreCard.updateStatisticsFor(assistStatistics, wid, faction) + //TODO may need to expand these to include other fields later + a.weapons.map { weq => + val wid = weq.equipment + (wid, ScoreCard.updateStatisticsFor(assistStatistics, wid, faction)) } case d: Death => - val expired = curr - curr = Life() - lives = expired.copy(death = Some(d)) +: lives - case _ => ; + curr = curr.copy(death = Some(d)) + Nil + case value: Long => + curr = curr.copy(supportExperience = curr.supportExperience + value) + Nil + case _ => + Nil } } } object ScoreCard { + def reviveCount(card: ScoreCard): Int = { + reviveCount(card.CurrentLife) + } + + def reviveCount(life: Life): Int = { + recursiveReviveCount(life, count = 0) + } + + def deathCount(card: ScoreCard): Int = { + card.AllLives.foldLeft(0)(_ + deathCount(_)) + } + + private def deathCount(life: Life): Int = { + life.prior match { + case None => if (life.death.nonEmpty) 1 else 0 + case Some(previousLife) => recursiveReviveCount(previousLife, count = 1) + } + } + + @tailrec + private def recursiveReviveCount(life: Life, count: Int): Int = { + life.prior match { + case None => count + 1 + case Some(previousLife) => recursiveReviveCount(previousLife, count + 1) + } + } + private def updateEquipmentStat(curr: Life, entry: EquipmentStat): Life = { - updateEquipmentStat(curr, entry, entry.objectId, entry.kills) + updateEquipmentStat(curr, entry, entry.objectId, entry.kills, entry.assists) } private def updateEquipmentStat( curr: Life, entry: EquipmentStat, objectId: Int, - killCount: Int + killCount: Int, + assists: Int ): Life = { curr.equipmentStats.indexWhere { a => a.objectId == objectId } match { case -1 => @@ -72,6 +126,29 @@ object ScoreCard { } } + @tailrec + private def initStatisticsFor( + statisticMap: mutable.HashMap[Int, Statistic], + objectId: Int, + victimFaction: PlanetSideEmpire.Value + ): Statistic = { + statisticMap.get(objectId) match { + case Some(fields) => + val outEntry = victimFaction match { + case PlanetSideEmpire.TR => fields.copy(tr_c = fields.tr_c + 1) + case PlanetSideEmpire.NC => fields.copy(nc_c = fields.nc_c + 1) + case PlanetSideEmpire.VS => fields.copy(vs_c = fields.vs_c + 1) + case PlanetSideEmpire.NEUTRAL => fields.copy(ps_c = fields.ps_c + 1) + } + statisticMap.put(objectId, outEntry) + outEntry + case _ => + val out = Statistic(0, 0, 0, 0, 0, 0, 0, 0) + statisticMap.put(objectId, out) + initStatisticsFor(statisticMap, objectId, victimFaction) + } + } + @tailrec private def updateStatisticsFor( statisticMap: mutable.HashMap[Int, Statistic], @@ -81,10 +158,10 @@ object ScoreCard { statisticMap.get(objectId) match { case Some(fields) => val outEntry = victimFaction match { - case PlanetSideEmpire.TR => fields.copy(tr_b = fields.tr_b + 1) - case PlanetSideEmpire.NC => fields.copy(nc_b = fields.nc_b + 1) - case PlanetSideEmpire.VS => fields.copy(vs_b = fields.vs_b + 1) - case PlanetSideEmpire.NEUTRAL => fields.copy(ps_b = fields.ps_b + 1) + case PlanetSideEmpire.TR => fields.copy(tr_s = fields.tr_s + 1) + case PlanetSideEmpire.NC => fields.copy(nc_s = fields.nc_s + 1) + case PlanetSideEmpire.VS => fields.copy(vs_s = fields.vs_s + 1) + case PlanetSideEmpire.NEUTRAL => fields.copy(ps_s = fields.ps_s + 1) } outEntry case _ => diff --git a/src/main/scala/net/psforever/objects/avatar/scoring/Statistic.scala b/src/main/scala/net/psforever/objects/avatar/scoring/Statistic.scala index a77ecc734..e6b5b03a3 100644 --- a/src/main/scala/net/psforever/objects/avatar/scoring/Statistic.scala +++ b/src/main/scala/net/psforever/objects/avatar/scoring/Statistic.scala @@ -1,4 +1,35 @@ // Copyright (c) 2023 PSForever package net.psforever.objects.avatar.scoring -final case class Statistic(tr_a: Int, tr_b: Int, nc_a: Int, nc_b: Int, vs_a: Int, vs_b: Int, ps_a: Int, ps_b: Int) +/** + * Organizes the eight fields one would find in an `AvatarServiceMessage` statistic field. + * The `_c` fields and the `_s` fields are paired when the values populate the packet + * where `c` stands for "campaign" and `s` stands for "session". + * "Session" values reflect on the UI as the K in K/D + * while "campaign" values reflect on the Character Info window, stats section. + * @param tr_c terran republic campaign stat + * @param tr_s terran republic session stat + * @param nc_c new conglomerate campaign stat + * @param nc_s new conglomerate session stat + * @param vs_c vanu sovereignty campaign stat + * @param vs_s vanu sovereignty session stat + * @param ps_c generic faction campaign stat + * @param ps_s generic faction session stat + */ +final case class Statistic(tr_c: Int, tr_s: Int, nc_c: Int, nc_s: Int, vs_c: Int, vs_s: Int, ps_c: Int, ps_s: Int) + +final case class StatisticByContext(tr: Int, nc: Int, vs: Int, ps: Int) { + def total: Int = tr + nc + vs + ps +} + +object CampaignStatistics { + def apply(stat: Statistic): StatisticByContext = { + StatisticByContext(stat.tr_c, stat.nc_c, stat.vs_c, stat.ps_c) + } +} + +object SessionStatistics { + def apply(stat: Statistic): StatisticByContext = { + StatisticByContext(stat.tr_s, stat.nc_s, stat.vs_s, stat.ps_s) + } +} diff --git a/src/main/scala/net/psforever/objects/ce/DeployableBehavior.scala b/src/main/scala/net/psforever/objects/ce/DeployableBehavior.scala index dba3e149a..b8ff9be0c 100644 --- a/src/main/scala/net/psforever/objects/ce/DeployableBehavior.scala +++ b/src/main/scala/net/psforever/objects/ce/DeployableBehavior.scala @@ -66,16 +66,16 @@ trait DeployableBehavior { finalizeDeployable(callback) case Deployable.Ownership(None) - if DeployableObject.Owner.nonEmpty => + if DeployableObject.OwnerGuid.nonEmpty => val obj = DeployableObject if (constructed.contains(true)) { loseOwnership(obj.Faction) } else { - obj.Owner = None + obj.OwnerGuid = None } case Deployable.Ownership(Some(player)) - if !DeployableObject.Destroyed && DeployableObject.Owner.isEmpty => + if !DeployableObject.Destroyed && DeployableObject.OwnerGuid.isEmpty => if (constructed.contains(true)) { gainOwnership(player) } else { @@ -132,12 +132,12 @@ trait DeployableBehavior { def startOwnerlessDecay(): Unit = { val obj = DeployableObject - if (obj.Owner.nonEmpty && decay.isCancelled) { + if (obj.OwnerGuid.nonEmpty && decay.isCancelled) { //without an owner, this deployable should begin to decay and will deconstruct later import scala.concurrent.ExecutionContext.Implicits.global decay = context.system.scheduler.scheduleOnce(Deployable.decay, self, Deployable.Deconstruct()) } - obj.Owner = None //OwnerName should remain set + obj.OwnerGuid = None //OwnerName should remain set } /** @@ -163,7 +163,7 @@ trait DeployableBehavior { val guid = obj.GUID val zone = obj.Zone val originalFaction = obj.Faction - val info = DeployableInfo(guid, DeployableIcon.Boomer, obj.Position, obj.Owner.get) + val info = DeployableInfo(guid, DeployableIcon.Boomer, obj.Position, obj.OwnerGuid.get) if (originalFaction != toFaction) { obj.Faction = toFaction val localEvents = zone.LocalEvents @@ -199,7 +199,7 @@ trait DeployableBehavior { zone.LocalEvents ! LocalServiceMessage( zone.id, LocalAction.TriggerEffectLocation( - obj.Owner.getOrElse(Service.defaultPlayerGUID), + obj.OwnerGuid.getOrElse(Service.defaultPlayerGUID), "spawn_object_effect", obj.Position, obj.Orientation @@ -234,7 +234,7 @@ trait DeployableBehavior { val obj = DeployableObject val zone = obj.Zone val localEvents = zone.LocalEvents - val owner = obj.Owner.getOrElse(Service.defaultPlayerGUID) + val owner = obj.OwnerGuid.getOrElse(Service.defaultPlayerGUID) obj.OwnerName match { case Some(_) => case None => @@ -306,7 +306,9 @@ trait DeployableBehavior { if (!obj.Destroyed) { Deployables.AnnounceDestroyDeployable(obj) } - obj.OwnerName = None + val guid = obj.OwnerGuid + obj.AssignOwnership(None) + obj.OwnerGuid = guid } } diff --git a/src/main/scala/net/psforever/objects/definition/VehicleDefinition.scala b/src/main/scala/net/psforever/objects/definition/VehicleDefinition.scala index c89fd5b6e..306fc1cbf 100644 --- a/src/main/scala/net/psforever/objects/definition/VehicleDefinition.scala +++ b/src/main/scala/net/psforever/objects/definition/VehicleDefinition.scala @@ -26,27 +26,8 @@ class VehicleDefinition(objectId: Int) with VitalityDefinition with NtuContainerDefinition with ResistanceProfileMutators - with DamageResistanceModel { - /** ... */ - var shieldUiAttribute: Int = 68 - /** how many points of shield the vehicle starts with (should default to 0 if unset through the accessor) */ - private var defaultShields : Option[Int] = None - /** maximum vehicle shields (generally: 20% of health) - * for normal vehicles, offered through amp station facility benefits - * for BFR's, it charges naturally - **/ - private var maxShields: Int = 0 - /** the minimum amount of time that must elapse in between damage and shield charge activities (ms) */ - private var shieldChargeDamageCooldown : Long = 5000L - /** the minimum amount of time that must elapse in between distinct shield charge activities (ms) */ - private var shieldChargePeriodicCooldown : Long = 1000L - /** if the shield recharges on its own, this value will be non-`None` and indicate by how much */ - private var autoShieldRecharge : Option[Int] = None - private var autoShieldRechargeSpecial : Option[Int] = None - /** shield drain is what happens to the shield under special conditions, e.g., bfr flight; - * the drain interval is 250ms which is convenient for us - * we can skip needing to define is explicitly */ - private var shieldDrain : Option[Int] = None + with DamageResistanceModel + with WithShields { private val cargo: mutable.HashMap[Int, CargoDefinition] = mutable.HashMap[Int, CargoDefinition]() private var deployment: Boolean = false private val utilities: mutable.HashMap[Int, UtilityType.Value] = mutable.HashMap() @@ -92,63 +73,6 @@ class VehicleDefinition(objectId: Int) RepairRestoresAt = 1 registerAs = "vehicles" - def DefaultShields: Int = defaultShields.getOrElse(0) - - def DefaultShields_=(shield: Int): Int = DefaultShields_=(Some(shield)) - - def DefaultShields_=(shield: Option[Int]): Int = { - defaultShields = shield - DefaultShields - } - - def MaxShields: Int = maxShields - - def MaxShields_=(shields: Int): Int = { - maxShields = shields - MaxShields - } - - def ShieldPeriodicDelay : Long = shieldChargePeriodicCooldown - - def ShieldPeriodicDelay_=(cooldown: Long): Long = { - shieldChargePeriodicCooldown = cooldown - ShieldPeriodicDelay - } - - def ShieldDamageDelay: Long = shieldChargeDamageCooldown - - def ShieldDamageDelay_=(cooldown: Long): Long = { - shieldChargeDamageCooldown = cooldown - ShieldDamageDelay - } - - def ShieldAutoRecharge: Option[Int] = autoShieldRecharge - - def ShieldAutoRecharge_=(charge: Int): Option[Int] = ShieldAutoRecharge_=(Some(charge)) - - def ShieldAutoRecharge_=(charge: Option[Int]): Option[Int] = { - autoShieldRecharge = charge - ShieldAutoRecharge - } - - def ShieldAutoRechargeSpecial: Option[Int] = autoShieldRechargeSpecial.orElse(ShieldAutoRecharge) - - def ShieldAutoRechargeSpecial_=(charge: Int): Option[Int] = ShieldAutoRechargeSpecial_=(Some(charge)) - - def ShieldAutoRechargeSpecial_=(charge: Option[Int]): Option[Int] = { - autoShieldRechargeSpecial = charge - ShieldAutoRechargeSpecial - } - - def ShieldDrain: Option[Int] = shieldDrain - - def ShieldDrain_=(drain: Int): Option[Int] = ShieldDrain_=(Some(drain)) - - def ShieldDrain_=(drain: Option[Int]): Option[Int] = { - shieldDrain = drain - ShieldDrain - } - def Cargo: mutable.HashMap[Int, CargoDefinition] = cargo def CanBeOwned: Option[Boolean] = canBeOwned @@ -300,6 +224,7 @@ class VehicleDefinition(objectId: Int) ) } + //noinspection ScalaUnusedSymbol def Uninitialize(obj: Vehicle, context: ActorContext): Unit = { obj.Actor ! akka.actor.PoisonPill obj.Actor = Default.Actor diff --git a/src/main/scala/net/psforever/objects/definition/WithShields.scala b/src/main/scala/net/psforever/objects/definition/WithShields.scala new file mode 100644 index 000000000..0d5b1b378 --- /dev/null +++ b/src/main/scala/net/psforever/objects/definition/WithShields.scala @@ -0,0 +1,81 @@ +package net.psforever.objects.definition + +trait WithShields { + /** ... */ + var shieldUiAttribute: Int = 68 + /** how many points of shield the vehicle starts with (should default to 0 if unset through the accessor) */ + private var defaultShields : Option[Int] = None + /** maximum vehicle shields (generally: 20% of health) + * for normal vehicles, offered through amp station facility benefits + * for BFR's, it charges naturally + **/ + private var maxShields: Int = 0 + /** the minimum amount of time that must elapse in between damage and shield charge activities (ms) */ + private var shieldChargeDamageCooldown : Long = 5000L + /** the minimum amount of time that must elapse in between distinct shield charge activities (ms) */ + private var shieldChargePeriodicCooldown : Long = 1000L + /** if the shield recharges on its own, this value will be non-`None` and indicate by how much */ + private var autoShieldRecharge : Option[Int] = None + private var autoShieldRechargeSpecial : Option[Int] = None + /** shield drain is what happens to the shield under special conditions, e.g., bfr flight; + * the drain interval is 250ms which is convenient for us + * we can skip needing to define is explicitly */ + private var shieldDrain : Option[Int] = None + + def DefaultShields: Int = defaultShields.getOrElse(0) + + def DefaultShields_=(shield: Int): Int = DefaultShields_=(Some(shield)) + + def DefaultShields_=(shield: Option[Int]): Int = { + defaultShields = shield + DefaultShields + } + + def MaxShields: Int = maxShields + + def MaxShields_=(shields: Int): Int = { + maxShields = shields + MaxShields + } + + def ShieldPeriodicDelay : Long = shieldChargePeriodicCooldown + + def ShieldPeriodicDelay_=(cooldown: Long): Long = { + shieldChargePeriodicCooldown = cooldown + ShieldPeriodicDelay + } + + def ShieldDamageDelay: Long = shieldChargeDamageCooldown + + def ShieldDamageDelay_=(cooldown: Long): Long = { + shieldChargeDamageCooldown = cooldown + ShieldDamageDelay + } + + def ShieldAutoRecharge: Option[Int] = autoShieldRecharge + + def ShieldAutoRecharge_=(charge: Int): Option[Int] = ShieldAutoRecharge_=(Some(charge)) + + def ShieldAutoRecharge_=(charge: Option[Int]): Option[Int] = { + autoShieldRecharge = charge + ShieldAutoRecharge + } + + def ShieldAutoRechargeSpecial: Option[Int] = autoShieldRechargeSpecial.orElse(ShieldAutoRecharge) + + def ShieldAutoRechargeSpecial_=(charge: Int): Option[Int] = ShieldAutoRechargeSpecial_=(Some(charge)) + + def ShieldAutoRechargeSpecial_=(charge: Option[Int]): Option[Int] = { + autoShieldRechargeSpecial = charge + ShieldAutoRechargeSpecial + } + + def ShieldDrain: Option[Int] = shieldDrain + + def ShieldDrain_=(drain: Int): Option[Int] = ShieldDrain_=(Some(drain)) + + def ShieldDrain_=(drain: Option[Int]): Option[Int] = { + shieldDrain = drain + ShieldDrain + } +} diff --git a/src/main/scala/net/psforever/objects/definition/converter/BattleFrameFlightConverter.scala b/src/main/scala/net/psforever/objects/definition/converter/BattleFrameFlightConverter.scala index 0d652db9e..7ed207187 100644 --- a/src/main/scala/net/psforever/objects/definition/converter/BattleFrameFlightConverter.scala +++ b/src/main/scala/net/psforever/objects/definition/converter/BattleFrameFlightConverter.scala @@ -28,7 +28,7 @@ class BattleFrameFlightConverter extends ObjectCreateConverter[Vehicle]() { jammered = false, v4 = None, v5 = None, - obj.Owner match { + obj.OwnerGuid match { case Some(owner) => owner case None => PlanetSideGUID(0) } diff --git a/src/main/scala/net/psforever/objects/definition/converter/BattleFrameRoboticsConverter.scala b/src/main/scala/net/psforever/objects/definition/converter/BattleFrameRoboticsConverter.scala index 1c51b1afb..411ff6a79 100644 --- a/src/main/scala/net/psforever/objects/definition/converter/BattleFrameRoboticsConverter.scala +++ b/src/main/scala/net/psforever/objects/definition/converter/BattleFrameRoboticsConverter.scala @@ -28,7 +28,7 @@ class BattleFrameRoboticsConverter extends ObjectCreateConverter[Vehicle]() { jammered = obj.Jammed, v4 = None, v5 = None, - obj.Owner match { + obj.OwnerGuid match { case Some(owner) => owner case None => PlanetSideGUID(0) } diff --git a/src/main/scala/net/psforever/objects/definition/converter/CaptureFlagConverter.scala b/src/main/scala/net/psforever/objects/definition/converter/CaptureFlagConverter.scala index aee98e8ec..bdd7d4906 100644 --- a/src/main/scala/net/psforever/objects/definition/converter/CaptureFlagConverter.scala +++ b/src/main/scala/net/psforever/objects/definition/converter/CaptureFlagConverter.scala @@ -17,7 +17,7 @@ class CaptureFlagConverter extends ObjectCreateConverter[CaptureFlag]() { case _ => Hackable.HackInfo(PlayerSource("", PlanetSideEmpire.NEUTRAL, Vector3.Zero), PlanetSideGUID(0), 0L, 0L) } - val millisecondsRemaining = TimeUnit.MILLISECONDS.convert(math.max(0, hackInfo.hackStartTime + hackInfo.hackDuration - System.nanoTime), TimeUnit.NANOSECONDS) + val millisecondsRemaining = math.max(0, hackInfo.hackStartTime + hackInfo.hackDuration - System.currentTimeMillis()) Success( CaptureFlagData( diff --git a/src/main/scala/net/psforever/objects/definition/converter/DroppodConverter.scala b/src/main/scala/net/psforever/objects/definition/converter/DroppodConverter.scala index 53a04ee47..7585759d0 100644 --- a/src/main/scala/net/psforever/objects/definition/converter/DroppodConverter.scala +++ b/src/main/scala/net/psforever/objects/definition/converter/DroppodConverter.scala @@ -27,7 +27,7 @@ class DroppodConverter extends ObjectCreateConverter[Vehicle]() { jammered = obj.Jammed, v4 = Some(false), v5 = None, - obj.Owner match { + obj.OwnerGuid match { case Some(owner) => owner case None => PlanetSideGUID(0) } diff --git a/src/main/scala/net/psforever/objects/definition/converter/FieldTurretConverter.scala b/src/main/scala/net/psforever/objects/definition/converter/FieldTurretConverter.scala index 1379c6060..ca0426512 100644 --- a/src/main/scala/net/psforever/objects/definition/converter/FieldTurretConverter.scala +++ b/src/main/scala/net/psforever/objects/definition/converter/FieldTurretConverter.scala @@ -26,7 +26,7 @@ class FieldTurretConverter extends ObjectCreateConverter[TurretDeployable]() { jammered = obj.Jammed, Some(false), None, - obj.Owner match { + obj.OwnerGuid match { case Some(owner) => owner case None => PlanetSideGUID(0) } diff --git a/src/main/scala/net/psforever/objects/definition/converter/ShieldGeneratorConverter.scala b/src/main/scala/net/psforever/objects/definition/converter/ShieldGeneratorConverter.scala index 74536ffbb..938b01c02 100644 --- a/src/main/scala/net/psforever/objects/definition/converter/ShieldGeneratorConverter.scala +++ b/src/main/scala/net/psforever/objects/definition/converter/ShieldGeneratorConverter.scala @@ -24,7 +24,7 @@ class ShieldGeneratorConverter extends ObjectCreateConverter[ShieldGeneratorDepl jammered = obj.Jammed, None, None, - obj.Owner match { + obj.OwnerGuid match { case Some(owner) => owner case None => PlanetSideGUID(0) } diff --git a/src/main/scala/net/psforever/objects/definition/converter/SmallDeployableConverter.scala b/src/main/scala/net/psforever/objects/definition/converter/SmallDeployableConverter.scala index 264beb5bb..b7b430737 100644 --- a/src/main/scala/net/psforever/objects/definition/converter/SmallDeployableConverter.scala +++ b/src/main/scala/net/psforever/objects/definition/converter/SmallDeployableConverter.scala @@ -25,7 +25,7 @@ class SmallDeployableConverter extends ObjectCreateConverter[Deployable]() { }, Some(false), None, - obj.Owner match { + obj.OwnerGuid match { case Some(owner) => owner case None => PlanetSideGUID(0) } diff --git a/src/main/scala/net/psforever/objects/definition/converter/SmallTurretConverter.scala b/src/main/scala/net/psforever/objects/definition/converter/SmallTurretConverter.scala index 48892b8f6..13ee2745f 100644 --- a/src/main/scala/net/psforever/objects/definition/converter/SmallTurretConverter.scala +++ b/src/main/scala/net/psforever/objects/definition/converter/SmallTurretConverter.scala @@ -26,7 +26,7 @@ class SmallTurretConverter extends ObjectCreateConverter[TurretDeployable]() { jammered = obj.Jammed, Some(true), None, - obj.Owner match { + obj.OwnerGuid match { case Some(owner) => owner case None => PlanetSideGUID(0) } diff --git a/src/main/scala/net/psforever/objects/definition/converter/TRAPConverter.scala b/src/main/scala/net/psforever/objects/definition/converter/TRAPConverter.scala index 1e85bb946..621fde9a3 100644 --- a/src/main/scala/net/psforever/objects/definition/converter/TRAPConverter.scala +++ b/src/main/scala/net/psforever/objects/definition/converter/TRAPConverter.scala @@ -24,7 +24,7 @@ class TRAPConverter extends ObjectCreateConverter[TrapDeployable]() { false, Some(true), None, - obj.Owner match { + obj.OwnerGuid match { case Some(owner) => owner case None => PlanetSideGUID(0) } diff --git a/src/main/scala/net/psforever/objects/definition/converter/TelepadDeployableConverter.scala b/src/main/scala/net/psforever/objects/definition/converter/TelepadDeployableConverter.scala index 65fd33ba0..f6e88d47f 100644 --- a/src/main/scala/net/psforever/objects/definition/converter/TelepadDeployableConverter.scala +++ b/src/main/scala/net/psforever/objects/definition/converter/TelepadDeployableConverter.scala @@ -28,7 +28,7 @@ class TelepadDeployableConverter extends ObjectCreateConverter[TelepadDeployable false, None, Some(router.guid), - obj.Owner.getOrElse(PlanetSideGUID(0)) + obj.OwnerGuid.getOrElse(PlanetSideGUID(0)) ), unk1 = 87, unk2 = 12 diff --git a/src/main/scala/net/psforever/objects/definition/converter/VehicleConverter.scala b/src/main/scala/net/psforever/objects/definition/converter/VehicleConverter.scala index 6ce558c2e..b363fcee4 100644 --- a/src/main/scala/net/psforever/objects/definition/converter/VehicleConverter.scala +++ b/src/main/scala/net/psforever/objects/definition/converter/VehicleConverter.scala @@ -27,7 +27,7 @@ class VehicleConverter extends ObjectCreateConverter[Vehicle]() { jammered = obj.Jammed, v4 = Some(false), v5 = None, - obj.Owner match { + obj.OwnerGuid match { case Some(owner) => owner case None => PlanetSideGUID(0) } diff --git a/src/main/scala/net/psforever/objects/serverobject/damage/DamageableVehicle.scala b/src/main/scala/net/psforever/objects/serverobject/damage/DamageableVehicle.scala index d1c2acf4b..2519ddc4e 100644 --- a/src/main/scala/net/psforever/objects/serverobject/damage/DamageableVehicle.scala +++ b/src/main/scala/net/psforever/objects/serverobject/damage/DamageableVehicle.scala @@ -5,13 +5,15 @@ import akka.actor.{Actor, Cancellable} import net.psforever.objects.{Vehicle, Vehicles} import net.psforever.objects.equipment.JammableUnit import net.psforever.objects.serverobject.damage.Damageable.Target +import net.psforever.objects.sourcing.VehicleSource import net.psforever.objects.vital.base.DamageResolution -import net.psforever.objects.vital.interaction.DamageResult +import net.psforever.objects.vital.interaction.{Adversarial, DamageResult} import net.psforever.objects.vital.resolution.ResolutionCalculations +import net.psforever.objects.zones.Zone +import net.psforever.objects.zones.exp.ToDatabase +import net.psforever.packet.game.DamageWithPositionMessage import net.psforever.services.Service import net.psforever.services.vehicle.{VehicleAction, VehicleServiceMessage} -import net.psforever.objects.zones.Zone -import net.psforever.packet.game.DamageWithPositionMessage import net.psforever.types.Vector3 import scala.concurrent.duration._ @@ -213,6 +215,18 @@ trait DamageableVehicle VehicleAction.PlanetsideAttribute(Service.defaultPlayerGUID, target.GUID, obj.Definition.shieldUiAttribute, 0) ) } + //database entry + cause.adversarial.collect { + case Adversarial(attacker, victim: VehicleSource, implement) => + ToDatabase.reportMachineDestruction( + attacker.CharId, + victim, + DamageableObject.HackedBy, + DamageableObject.MountedIn.nonEmpty, + implement, + obj.Zone.Number + ) + } //clean up target.Actor ! Vehicle.Deconstruct(Some(1 minute)) DamageableWeaponTurret.DestructionAwareness(obj, cause) diff --git a/src/main/scala/net/psforever/objects/serverobject/hackable/Hackable.scala b/src/main/scala/net/psforever/objects/serverobject/hackable/Hackable.scala index 16f12d5b4..a494f2d85 100644 --- a/src/main/scala/net/psforever/objects/serverobject/hackable/Hackable.scala +++ b/src/main/scala/net/psforever/objects/serverobject/hackable/Hackable.scala @@ -25,14 +25,14 @@ trait Hackable { def HackedBy_=(agent: Option[Player]): Option[HackInfo] = { (hackedBy, agent) match { case (None, Some(actor)) => - hackedBy = Some(HackInfo(PlayerSource(actor), actor.GUID, System.nanoTime, 0L)) + hackedBy = Some(HackInfo(PlayerSource(actor), actor.GUID, System.currentTimeMillis(), 0L)) case (Some(info), Some(actor)) => if (actor.Faction == this.Faction) { //hack cleared hackedBy = None } else if (actor.Faction != info.hackerFaction) { //override the hack state with a new hack state if the new user has different faction affiliation - hackedBy = Some(HackInfo(PlayerSource(actor), actor.GUID, System.nanoTime, 0L)) + hackedBy = Some(HackInfo(PlayerSource(actor), actor.GUID, System.currentTimeMillis(), 0L)) } case (_, None) => hackedBy = None diff --git a/src/main/scala/net/psforever/objects/serverobject/llu/CaptureFlag.scala b/src/main/scala/net/psforever/objects/serverobject/llu/CaptureFlag.scala index 3d1dd5ab0..d13627e9f 100644 --- a/src/main/scala/net/psforever/objects/serverobject/llu/CaptureFlag.scala +++ b/src/main/scala/net/psforever/objects/serverobject/llu/CaptureFlag.scala @@ -8,7 +8,7 @@ import net.psforever.types.{PlanetSideEmpire, Vector3} /** * Represent a special entity that is carried by the player in certain circumstances. * The entity is not a piece of `Equipment` so it does not go into the holsters, - * doe not into the player's inventory, + * does not into the player's inventory, * and is not carried in or manipulated by the player's hands. * The different game elements it simulates are: * a facility's lattice logic unit (LLU), @@ -33,6 +33,7 @@ class CaptureFlag(private val tDef: CaptureFlagDefinition) extends Amenity { private var target: Building = Building.NoBuilding private var faction: PlanetSideEmpire.Value = PlanetSideEmpire.NEUTRAL private var carrier: Option[Player] = None + private var lastTimeCollected: Long = System.currentTimeMillis() def Target: Building = target def Target_=(newTarget: Building): Building = { @@ -64,8 +65,11 @@ class CaptureFlag(private val tDef: CaptureFlagDefinition) extends Amenity { def Carrier: Option[Player] = carrier def Carrier_=(newCarrier: Option[Player]) : Option[Player] = { carrier = newCarrier + lastTimeCollected = System.currentTimeMillis() carrier } + + def LastCollectionTime: Long = carrier.map { _ => lastTimeCollected }.getOrElse { System.currentTimeMillis() } } object CaptureFlag { diff --git a/src/main/scala/net/psforever/objects/serverobject/pad/VehicleSpawnControl.scala b/src/main/scala/net/psforever/objects/serverobject/pad/VehicleSpawnControl.scala index bc0526a86..0fa40b9e8 100644 --- a/src/main/scala/net/psforever/objects/serverobject/pad/VehicleSpawnControl.scala +++ b/src/main/scala/net/psforever/objects/serverobject/pad/VehicleSpawnControl.scala @@ -1,15 +1,17 @@ // Copyright (c) 2017 PSForever package net.psforever.objects.serverobject.pad -import akka.actor.{Cancellable, Props} +import akka.actor.{ActorRef, Cancellable, OneForOneStrategy, Props} import net.psforever.objects.avatar.SpecialCarry import net.psforever.objects.entity.WorldEntity import net.psforever.objects.guid.{GUIDTask, TaskWorkflow} import net.psforever.objects.serverobject.affinity.{FactionAffinity, FactionAffinityBehavior} import net.psforever.objects.serverobject.pad.process.{VehicleSpawnControlBase, VehicleSpawnControlConcealPlayer} +import net.psforever.objects.sourcing.AmenitySource +import net.psforever.objects.vital.TerminalUsedActivity import net.psforever.objects.zones.{Zone, ZoneAware, Zoning} import net.psforever.objects.{Default, PlanetSideGameObject, Player, Vehicle} -import net.psforever.types.Vector3 +import net.psforever.types.{PlanetSideGUID, TransactionType, Vector3} import scala.annotation.tailrec import scala.concurrent.ExecutionContext.Implicits.global @@ -38,38 +40,39 @@ class VehicleSpawnControl(pad: VehicleSpawnPad) with FactionAffinityBehavior.Check { /** a reminder sent to future customers */ - var periodicReminder: Cancellable = Default.Cancellable + private var periodicReminder: Cancellable = Default.Cancellable /** repeatedly test whether queued orders are valid */ - var queueManagement: Cancellable = Default.Cancellable + private var queueManagement: Cancellable = Default.Cancellable /** a list of vehicle orders that have been submitted for this spawn pad */ - var orders: List[VehicleSpawnPad.VehicleOrder] = List.empty[VehicleSpawnPad.VehicleOrder] + private var orders: List[VehicleSpawnPad.VehicleOrder] = List.empty[VehicleSpawnPad.VehicleOrder] /** the current vehicle order being acted upon; * used as a guard condition to control order processing rate */ - var trackedOrder: Option[VehicleSpawnControl.Order] = None + private var trackedOrder: Option[VehicleSpawnControl.Order] = None /** how to process either the first order or every subsequent order */ - var handleOrderFunc: VehicleSpawnPad.VehicleOrder => Unit = NewTasking + private var handleOrderFunc: VehicleSpawnPad.VehicleOrder => Unit = NewTasking def LogId = "" /** * The first chained action of the vehicle spawning process. */ - val concealPlayer = + private val concealPlayer: ActorRef = context.actorOf(Props(classOf[VehicleSpawnControlConcealPlayer], pad), s"${context.parent.path.name}-conceal") def FactionObject: FactionAffinity = pad import akka.actor.SupervisorStrategy._ - override val supervisorStrategy = { - import akka.actor.OneForOneStrategy + + override val supervisorStrategy: OneForOneStrategy = { OneForOneStrategy(maxNrOfRetries = 10, withinTimeRange = 10 seconds) { - case _: akka.actor.ActorKilledException => Restart - case _ => Resume + case _ => + log.warn(s"vehicle spawn pad restarted${trackedOrder.map { o => s"; an unfulfilled order for ${o.driver.Name} will be expunged" }.getOrElse("")}") + Restart } } @@ -85,21 +88,18 @@ class VehicleSpawnControl(pad: VehicleSpawnPad) try { handleOrderFunc(msg) } catch { - case _: AssertionError => ; //ehhh - case e: Exception => //something unexpected - e.printStackTrace() + case _: AssertionError => () //ehhh + case e: Exception => e.printStackTrace() //something unexpected } case VehicleSpawnControl.ProcessControl.OrderCancelled => - trackedOrder match { - case Some(entry) - if sender() == concealPlayer => + trackedOrder.collect { + case entry if sender() == concealPlayer => CancelOrder( entry, VehicleSpawnControl.validateOrderCredentials(pad, entry.driver, entry.vehicle) .orElse(Some("@SVCP_RemovedFromVehicleQueue_Generic")) ) - case _ => ; } trackedOrder = None //guard off SelectOrder() @@ -120,37 +120,40 @@ class VehicleSpawnControl(pad: VehicleSpawnPad) During this time, a periodic message about the spawn pad being blocked will be broadcast to the order queue. */ case VehicleSpawnControl.ProcessControl.Reminder => - trackedOrder match { - case Some(entry) => - if (periodicReminder.isCancelled) { - trace(s"the pad has become blocked by a ${entry.vehicle.Definition.Name} in its current order") - periodicReminder = context.system.scheduler.scheduleWithFixedDelay( - VehicleSpawnControl.periodicReminderTestDelay, - VehicleSpawnControl.periodicReminderTestDelay, - self, - VehicleSpawnControl.ProcessControl.Reminder - ) - } else { - BlockedReminder(entry, orders) - } - case None => ; + trackedOrder + .collect { + case entry => + if (periodicReminder.isCancelled) { + trace(s"the pad has become blocked by a ${entry.vehicle.Definition.Name} in its current order") + periodicReminder = context.system.scheduler.scheduleWithFixedDelay( + VehicleSpawnControl.periodicReminderTestDelay, + VehicleSpawnControl.periodicReminderTestDelay, + self, + VehicleSpawnControl.ProcessControl.Reminder + ) + } else { + BlockedReminder(entry, orders) + } + trackedOrder + } + .orElse { periodicReminder.cancel() - } + None + } case VehicleSpawnControl.ProcessControl.Flush => periodicReminder.cancel() orders.foreach { CancelOrder(_, Some("@SVCP_RemovedFromVehicleQueue_Generic")) } orders = Nil - trackedOrder match { - case Some(entry) => CancelOrder(entry, Some("@SVCP_RemovedFromVehicleQueue_Generic")) - case None => ; + trackedOrder.foreach { + entry => CancelOrder(entry, Some("@SVCP_RemovedFromVehicleQueue_Generic")) } trackedOrder = None handleOrderFunc = NewTasking pad.Zone.VehicleEvents ! VehicleSpawnPad.ResetSpawnPad(pad) //cautious animation reset - concealPlayer ! akka.actor.Kill //should cause the actor to restart, which will abort any trapped messages + self ! akka.actor.Kill //should cause the actor to restart, which will abort any trapped messages - case _ => ; + case _ => () } /** @@ -158,7 +161,7 @@ class VehicleSpawnControl(pad: VehicleSpawnPad) * All orders accepted in the meantime will be queued and a note about priority will be issued. * @param order the order being accepted */ - def NewTasking(order: VehicleSpawnPad.VehicleOrder): Unit = { + private def NewTasking(order: VehicleSpawnPad.VehicleOrder): Unit = { handleOrderFunc = QueuedTasking ProcessOrder(Some(order)) } @@ -168,7 +171,7 @@ class VehicleSpawnControl(pad: VehicleSpawnPad) * all orders accepted in the meantime will be queued and a note about priority will be issued. * @param order the order being accepted */ - def QueuedTasking(order: VehicleSpawnPad.VehicleOrder): Unit = { + private def QueuedTasking(order: VehicleSpawnPad.VehicleOrder): Unit = { val name = order.player.Name if (trackedOrder match { case Some(tracked) => @@ -219,14 +222,14 @@ class VehicleSpawnControl(pad: VehicleSpawnPad) /** * Select the next available queued order and begin processing it. */ - def SelectOrder(): Unit = ProcessOrder(SelectFirstOrder()) + private def SelectOrder(): Unit = ProcessOrder(SelectFirstOrder()) /** * Select the next-available queued order if there is no current order being fulfilled. * If the queue has been exhausted, set functionality to prepare to accept the next order as a "first order." * @return the next-available order */ - def SelectFirstOrder(): Option[VehicleSpawnPad.VehicleOrder] = { + private def SelectFirstOrder(): Option[VehicleSpawnPad.VehicleOrder] = { trackedOrder match { case None => val (completeOrder, remainingOrders): (Option[VehicleSpawnPad.VehicleOrder], List[VehicleSpawnPad.VehicleOrder]) = @@ -255,14 +258,12 @@ class VehicleSpawnControl(pad: VehicleSpawnPad) * @param order the order being accepted; * `None`, if no order found or submitted */ - def ProcessOrder(order: Option[VehicleSpawnPad.VehicleOrder]): Unit = { + private def ProcessOrder(order: Option[VehicleSpawnPad.VehicleOrder]): Unit = { periodicReminder.cancel() - order match { - case Some(_order) => + order.collect { + case VehicleSpawnPad.VehicleOrder(driver, vehicle, terminal) => val size = orders.size + 1 - val driver = _order.player val name = driver.Name - val vehicle = _order.vehicle val newOrder = VehicleSpawnControl.Order(driver, vehicle) recursiveOrderReminder(orders.iterator, size) trace(s"processing next order - a ${vehicle.Definition.Name} for $name") @@ -273,7 +274,7 @@ class VehicleSpawnControl(pad: VehicleSpawnPad) ) trackedOrder = Some(newOrder) //guard on context.system.scheduler.scheduleOnce(2000 milliseconds, concealPlayer, newOrder) - case None => ; + driver.LogActivity(TerminalUsedActivity(AmenitySource(terminal), TransactionType.Buy)) } } @@ -282,7 +283,7 @@ class VehicleSpawnControl(pad: VehicleSpawnPad) * either start a periodic examination of those credentials until the queue has been emptied or * cancel a running periodic examination if the queue is already empty. */ - def queueManagementTask(): Unit = { + private def queueManagementTask(): Unit = { if (orders.nonEmpty) { orders = orderCredentialsCheck(orders).toList if (queueManagement.isCancelled) { @@ -306,7 +307,7 @@ class VehicleSpawnControl(pad: VehicleSpawnPad) * @param recipients the original list of orders * @return the list of still-acceptable orders */ - def orderCredentialsCheck(recipients: Iterable[VehicleSpawnPad.VehicleOrder]): Iterable[VehicleSpawnPad.VehicleOrder] = { + private def orderCredentialsCheck(recipients: Iterable[VehicleSpawnPad.VehicleOrder]): Iterable[VehicleSpawnPad.VehicleOrder] = { recipients .map { order => (order, VehicleSpawnControl.validateOrderCredentials(order.terminal, order.player, order.vehicle)) @@ -328,10 +329,10 @@ class VehicleSpawnControl(pad: VehicleSpawnPad) * @param blockedOrder the previous order whose vehicle is blocking the spawn pad from operating * @param recipients all of the other customers who will be receiving the message */ - def BlockedReminder(blockedOrder: VehicleSpawnControl.Order, recipients: Seq[VehicleSpawnPad.VehicleOrder]): Unit = { + private def BlockedReminder(blockedOrder: VehicleSpawnControl.Order, recipients: Seq[VehicleSpawnPad.VehicleOrder]): Unit = { val user = blockedOrder.vehicle .Seats(0).occupant - .orElse(pad.Zone.GUID(blockedOrder.vehicle.Owner)) + .orElse(pad.Zone.GUID(blockedOrder.vehicle.OwnerGuid)) .orElse(pad.Zone.GUID(blockedOrder.DriverGUID)) val relevantRecipients: Iterator[VehicleSpawnPad.VehicleOrder] = user match { case Some(p: Player) if !p.HasGUID => @@ -358,14 +359,14 @@ class VehicleSpawnControl(pad: VehicleSpawnPad) * Cancel this vehicle order and inform the person who made it, if possible. * @param entry the order being cancelled */ - def CancelOrder(entry: VehicleSpawnControl.Order, msg: Option[String]): Unit = { + private def CancelOrder(entry: VehicleSpawnControl.Order, msg: Option[String]): Unit = { CancelOrder(entry.vehicle, entry.driver, msg) } /** * Cancel this vehicle order and inform the person who made it, if possible. * @param entry the order being cancelled */ - def CancelOrder(entry: VehicleSpawnPad.VehicleOrder, msg: Option[String]): Unit = { + private def CancelOrder(entry: VehicleSpawnPad.VehicleOrder, msg: Option[String]): Unit = { CancelOrder(entry.vehicle, entry.player, msg) } /** @@ -373,7 +374,7 @@ class VehicleSpawnControl(pad: VehicleSpawnPad) * @param vehicle the vehicle from the order being cancelled * @param player the player who would driver the vehicle from the order being cancelled */ - def CancelOrder(vehicle: Vehicle, player: Player, msg: Option[String]): Unit = { + private def CancelOrder(vehicle: Vehicle, player: Player, msg: Option[String]): Unit = { if (vehicle.Seats.values.count(_.isOccupied) == 0) { VehicleSpawnControl.DisposeSpawnedVehicle(vehicle, player, pad.Zone) pad.Zone.VehicleEvents ! VehicleSpawnPad.PeriodicReminder(player.Name, VehicleSpawnPad.Reminders.Cancelled, msg) @@ -436,8 +437,8 @@ object VehicleSpawnControl { final case class Order(driver: Player, vehicle: Vehicle) { assert(driver.HasGUID, s"when ordering a vehicle, the prospective driver ${driver.Name} does not have a GUID") assert(vehicle.HasGUID, s"when ordering a vehicle, the ${vehicle.Definition.Name} does not have a GUID") - val DriverGUID = driver.GUID - val time = System.currentTimeMillis() + val DriverGUID: PlanetSideGUID = driver.GUID + val time: Long = System.currentTimeMillis() } /** @@ -502,7 +503,7 @@ object VehicleSpawnControl { * @param player the player who would own the vehicle being disposed * @param zone the zone in which the vehicle is registered (should be located) */ - def DisposeSpawnedVehicle(vehicle: Vehicle, player: Player, zone: Zone): Unit = { + private def DisposeSpawnedVehicle(vehicle: Vehicle, player: Player, zone: Zone): Unit = { DisposeVehicle(vehicle, zone) zone.VehicleEvents ! VehicleSpawnPad.RevealPlayer(player.GUID) } diff --git a/src/main/scala/net/psforever/objects/serverobject/resourcesilo/ResourceSiloControl.scala b/src/main/scala/net/psforever/objects/serverobject/resourcesilo/ResourceSiloControl.scala index 96bca9257..a1cf825be 100644 --- a/src/main/scala/net/psforever/objects/serverobject/resourcesilo/ResourceSiloControl.scala +++ b/src/main/scala/net/psforever/objects/serverobject/resourcesilo/ResourceSiloControl.scala @@ -7,11 +7,13 @@ import net.psforever.actors.zone.BuildingActor import net.psforever.objects.serverobject.affinity.{FactionAffinity, FactionAffinityBehavior} import net.psforever.objects.serverobject.transfer.TransferBehavior import net.psforever.objects.serverobject.structures.Building -import net.psforever.objects.{GlobalDefinitions, Ntu, NtuContainer, NtuStorageBehavior} -import net.psforever.types.PlanetSideEmpire +import net.psforever.objects.zones +import net.psforever.objects.{GlobalDefinitions, Ntu, NtuContainer, NtuStorageBehavior, Vehicle} +import net.psforever.types.{ExperienceType, PlanetSideEmpire} import net.psforever.services.Service import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage} import net.psforever.services.vehicle.{VehicleAction, VehicleServiceMessage} +import net.psforever.util.Config import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.duration._ @@ -181,6 +183,24 @@ class ResourceSiloControl(resourceSilo: ResourceSilo) if (amount != 0) { panelAnimationFunc(sender, amount) panelAnimationFunc = SkipPanelAnimation + (src match { + case v: Vehicle => Some(v) + case _ => None + }) + .map { v => (v, v.Owners) } + .collect { case (vehicle, Some(owner)) => + //experience is reported as normal + val deposit: Long = + (Config.app.game.experience.sep.ntuSiloDepositReward.toFloat * + math.floor(amount).toFloat / + math.floor(resourceSilo.MaxNtuCapacitor / resourceSilo.Definition.ChargeTime.toMillis.toFloat) + ).toLong + vehicle.Zone.AvatarEvents ! AvatarServiceMessage( + owner.name, + AvatarAction.AwardBep(0, deposit, ExperienceType.Normal) + ) + zones.exp.ToDatabase.reportNtuActivity(owner.charId, resourceSilo.Zone.Number, resourceSilo.Owner.GUID.guid, deposit) + } } } @@ -192,6 +212,7 @@ class ResourceSiloControl(resourceSilo: ResourceSilo) * @param trigger if positive, activate the animation; * if negative or zero, disable the animation */ + //noinspection ScalaUnusedSymbol def PanelAnimation(source: ActorRef, trigger: Float): Unit = { val currentlyHas = resourceSilo.NtuCapacitor // do not let the trigger charge go to waste, but also do not let the silo be filled diff --git a/src/main/scala/net/psforever/objects/serverobject/resourcesilo/ResourceSiloDefinition.scala b/src/main/scala/net/psforever/objects/serverobject/resourcesilo/ResourceSiloDefinition.scala index eda1b65ad..cb07b97a0 100644 --- a/src/main/scala/net/psforever/objects/serverobject/resourcesilo/ResourceSiloDefinition.scala +++ b/src/main/scala/net/psforever/objects/serverobject/resourcesilo/ResourceSiloDefinition.scala @@ -4,12 +4,16 @@ package net.psforever.objects.serverobject.resourcesilo import net.psforever.objects.NtuContainerDefinition import net.psforever.objects.serverobject.structures.AmenityDefinition +import scala.concurrent.duration._ + /** * The definition for any `Resource Silo`. * Object Id 731. */ class ResourceSiloDefinition extends AmenityDefinition(731) with NtuContainerDefinition { + var ChargeTime: FiniteDuration = 0.seconds + Name = "resource_silo" MaxNtuCapacitor = 1000 } diff --git a/src/main/scala/net/psforever/objects/serverobject/structures/Building.scala b/src/main/scala/net/psforever/objects/serverobject/structures/Building.scala index ab1bf430b..df9aa66c6 100644 --- a/src/main/scala/net/psforever/objects/serverobject/structures/Building.scala +++ b/src/main/scala/net/psforever/objects/serverobject/structures/Building.scala @@ -16,11 +16,10 @@ import net.psforever.types._ import scalax.collection.{Graph, GraphEdge} import akka.actor.typed.scaladsl.adapter._ import net.psforever.objects.serverobject.llu.{CaptureFlag, CaptureFlagSocket} +import net.psforever.objects.serverobject.structures.participation.{MajorFacilityHackParticipation, NoParticipation, ParticipationLogic, TowerHackParticipation} import net.psforever.objects.serverobject.terminals.capture.CaptureTerminal -import scala.collection.mutable import java.util.concurrent.TimeUnit -import scala.concurrent.duration._ class Building( private val name: String, @@ -34,9 +33,8 @@ class Building( private var faction: PlanetSideEmpire.Value = PlanetSideEmpire.NEUTRAL private var playersInSOI: List[Player] = List.empty - private val capitols = List("Thoth", "Voltan", "Neit", "Anguta", "Eisa", "Verica") private var forceDomeActive: Boolean = false - private var participationFunc: Building.ParticipationLogic = Building.NoParticipation + private var participationFunc: ParticipationLogic = NoParticipation super.Zone_=(zone) super.GUID_=(PlanetSideGUID(building_guid)) //set Invalidate() //unset; guid can be used during setup, but does not stop being registered properly later @@ -51,10 +49,11 @@ class Building( */ def MapId: Int = map_id - def IsCapitol: Boolean = capitols.contains(name) + def IsCapitol: Boolean = Building.Capitols.contains(name) + def IsSubCapitol: Boolean = { Neighbours match { - case Some(buildings: Set[Building]) => buildings.exists(x => capitols.contains(x.name)) + case Some(buildings: Set[Building]) => buildings.exists(x => Building.Capitols.contains(x.name)) case None => false } } @@ -85,13 +84,11 @@ class Building( box.Actor ! Painbox.Stop() } } - participationFunc.Players(building = this, list) playersInSOI = list + participationFunc.TryUpdate() playersInSOI } - def PlayerContribution: Map[Player, Long] = participationFunc.Contribution() - // Get all lattice neighbours def AllNeighbours: Option[Set[Building]] = { zone.Lattice find this match { @@ -186,8 +183,7 @@ class Building( case Some(obj: CaptureTerminal with Hackable) => obj.HackedBy match { case Some(Hackable.HackInfo(p, _, start, length)) => - val hack_time_remaining_ms = - TimeUnit.MILLISECONDS.convert(math.max(0, start + length - System.nanoTime), TimeUnit.NANOSECONDS) + val hack_time_remaining_ms = math.max(0, start + length - System.currentTimeMillis()) (true, p.Faction, hack_time_remaining_ms) case _ => (false, PlanetSideEmpire.NEUTRAL, 0L) @@ -360,43 +356,24 @@ class Building( override def Amenities_=(obj: Amenity): List[Amenity] = { obj match { - case _: CaptureTerminal => participationFunc = Building.FacilityHackParticipation - case _ => ; + case _: CaptureTerminal => + if (buildingType == StructureType.Facility) { + participationFunc = MajorFacilityHackParticipation(this) + } else if (buildingType == StructureType.Tower) { + participationFunc = TowerHackParticipation(this) + } + case _ => () } super.Amenities_=(obj) } + def Participation: ParticipationLogic = participationFunc + def Definition: BuildingDefinition = buildingDefinition } object Building { - trait ParticipationLogic { - def Players(building: Building, list: List[Player]): Unit = { } - def Contribution(): Map[Player, Long] - } - - final case object NoParticipation extends ParticipationLogic { - def Contribution(): Map[Player, Long] = Map.empty[Player, Long] - } - - final case object FacilityHackParticipation extends ParticipationLogic { - private var playerContribution: mutable.HashMap[Player, Long] = mutable.HashMap[Player, Long]() - - override def Players(building: Building, list: List[Player]): Unit = { - if (list.isEmpty) { - playerContribution.clear() - } else { - val hackTime = (building.CaptureTerminal.get.Definition.FacilityHackTime + 10.minutes).toMillis - val curr = System.currentTimeMillis() - val list2 = list.map { p => (p, curr) } - playerContribution = playerContribution.filterNot { case (p, t) => - list2.contains(p) || curr - t > hackTime - } ++ list2 - } - } - - def Contribution(): Map[Player, Long] = playerContribution.toMap - } + final val Capitols = List("Thoth", "Voltan", "Neit", "Anguta", "Eisa", "Verica") final val NoBuilding: Building = new Building(name = "", 0, map_id = 0, Zone.Nowhere, StructureType.Platform, GlobalDefinitions.building) { diff --git a/src/main/scala/net/psforever/objects/serverobject/structures/participation/FacilityHackParticipation.scala b/src/main/scala/net/psforever/objects/serverobject/structures/participation/FacilityHackParticipation.scala new file mode 100644 index 000000000..c64c31156 --- /dev/null +++ b/src/main/scala/net/psforever/objects/serverobject/structures/participation/FacilityHackParticipation.scala @@ -0,0 +1,272 @@ +// Copyright (c) 2023 PSForever +package net.psforever.objects.serverobject.structures.participation + +import net.psforever.objects.Player +import net.psforever.objects.avatar.scoring.Kill +import net.psforever.objects.sourcing.{PlayerSource, UniquePlayer} +import net.psforever.types.{PlanetSideEmpire, Vector3} + +import scala.collection.mutable + +trait FacilityHackParticipation extends ParticipationLogic { + protected var lastInfoRequest: Long = 0L + protected var infoRequestOverTime: Seq[Long] = Seq[Long]() + /** + key: unique player identifier
+ values: last player entry, number of times updated, time of last update (POSIX time) + */ + protected var playerContribution: mutable.LongMap[(Player, Int, Long)] = mutable.LongMap[(Player, Int, Long)]() + protected var playerPopulationOverTime: Seq[Map[PlanetSideEmpire.Value, Int]] = Seq[Map[PlanetSideEmpire.Value, Int]]() + + def PlayerContribution(timeDelay: Long): Map[Long, Float] = { + playerContribution + .collect { + case (id, (_, d, _)) if d < timeDelay => (id, d.toFloat / timeDelay.toFloat) + case (id, (_, _, _)) => (id, 1.0f) + } + .toMap[Long, Float] + } + + protected def updatePlayers(list: List[Player]): Unit = { + val hackTime = building.CaptureTerminal.get.Definition.FacilityHackTime.toMillis + val curr = System.currentTimeMillis() + if (list.isEmpty) { + playerContribution = playerContribution.filterNot { case (_, (_, _, t)) => curr - t > hackTime } + } else { + val (vanguardParticipants, missingParticipants) = { + val uniqueList2 = list.map { _.CharId } + playerContribution + .filterNot { case (_, (_, _, t)) => curr - t > hackTime } + .partition { case (p, _) => uniqueList2.contains(p) } + } + val newParticipaants = list + .filterNot { p => + playerContribution.exists { case (u, _) => p.CharId == u } + } + playerContribution = + vanguardParticipants.map { case (u, (p, d, _)) => (u, (p, d + 1, curr)) } ++ + newParticipaants.map { p => (p.CharId, (p, 1, curr)) } ++ + missingParticipants + } + } + + /** + * Eliminate participation for players who have no submitted updates within the time period. + * @param list current list of players + * @param now current time (ms) + * @param before how long before the current time beyond which players should be eliminated (ms) + * @see `timeSensitiveFilterAndAppend` + */ + protected def updatePopulationOverTime(list: List[Player], now: Long, before: Long): Unit = { + var populationList = list + val layer = PlanetSideEmpire.values.map { faction => + val (isFaction, everyoneElse) = populationList.partition(_.Faction == faction) + populationList = everyoneElse + (faction, isFaction.size) + }.toMap[PlanetSideEmpire.Value, Int] + playerPopulationOverTime = timeSensitiveFilterAndAppend(playerPopulationOverTime, layer, now - before) + } + + protected def updateTime(now: Long): Unit = { + infoRequestOverTime = timeSensitiveFilterAndAppend(infoRequestOverTime, now, now - 900000L) + } + + /** + * Eliminate entries from the primary input list based on time entries in a secondary time record list. + * The time record list must be updated independently. + * @param list input list whose entries are edited against time and then is appended + * @param newEntry new entry of the appropriate type to append to the end of the output list + * @param beforeTime how long before the current time beyond which entries in the input list should be eliminated (ms) + * @tparam T it does not matter what the type is + * @return the modified list + */ + protected def timeSensitiveFilterAndAppend[T]( + list: Seq[T], + newEntry: T, + beforeTime: Long + ): Seq[T] = { + infoRequestOverTime match { + case Nil => Seq(newEntry) + case _ => + (infoRequestOverTime.indexWhere { _ >= beforeTime } match { + case -1 => list + case cutOffIndex => list.drop(cutOffIndex) + }) :+ newEntry + } + } +} + +object FacilityHackParticipation { + private[participation] def allocateKillsByPlayers( + center: Vector3, + radius: Float, + hackStart: Long, + completionTime: Long, + opposingFaction: PlanetSideEmpire.Value, + contributionVictor: Iterable[(Player, Int, Long)], + ): Iterable[(UniquePlayer, Float, Seq[Kill])] = { + val killMapFunc: Iterable[(Player, Int, Long)] => Iterable[(UniquePlayer, Float, Seq[Kill])] = { + killsEarnedPerPlayerDuringHack(center.xy, radius * radius, hackStart, hackStart + completionTime, opposingFaction) + } + killMapFunc(contributionVictor) + } + + private[participation] def calculateExperienceFromKills( + killMapValues: Iterable[(UniquePlayer, Float, Seq[Kill])], + contributionOpposingSize: Int + ): Long = { + val totalExperienceFromKills = killMapValues + .flatMap { _._3.map { _.experienceEarned } } + .sum + .toFloat + (totalExperienceFromKills * contributionOpposingSize.toFloat * 0.1d).toLong + } + + private[participation] def killsEarnedPerPlayerDuringHack( + centerXY: Vector3, + distanceSq: Float, + start: Long, + end: Long, + faction: PlanetSideEmpire.Value + ) + ( + list: Iterable[(Player, Int, Long)] + ): Iterable[(UniquePlayer, Float, Seq[Kill])] = { + val duration = end - start + list.map { case (p, d, _) => + val killList = p.avatar.scorecard.Kills.filter { k => + val killTime = k.info.interaction.hitTime + k.victim.Faction == faction && + start < killTime && + killTime <= end && + Vector3.DistanceSquared(centerXY, k.info.interaction.hitPos.xy) < distanceSq + } + (PlayerSource(p).unique, math.min(d, duration).toFloat / duration.toFloat, killList) + } + } + + private[participation] def diffHeatForFactionMap( + data: mutable.HashMap[Vector3, Map[PlanetSideEmpire.Value, Seq[Long]]], + faction: PlanetSideEmpire.Value + ): Map[Vector3, Seq[Long]] = { + var lastHeatAmount: Long = 0 + var outList: Seq[Long] = Seq[Long]() + data.map { case (key, map) => + map(faction) match { + case Nil => () + case value :: Nil => + outList = outList :+ value + case value :: list => + lastHeatAmount = value + list.foreach { heat => + if (heat < lastHeatAmount) { + lastHeatAmount = heat + outList = outList :+ heat + } else { + outList = outList :+ (heat - lastHeatAmount) + lastHeatAmount = heat + } + } + } + (key, outList) + }.toMap[Vector3, Seq[Long]] + } + + private[participation] def heatMapComparison( + victorData: Iterable[Seq[Long]], + opposedData: Iterable[Seq[Long]] + ): Float = { + var dataCount: Int = 0 + var dataSum: Float = 0 + if (victorData.size == opposedData.size) { + val seq1 = victorData.toSeq + val seq2 = opposedData.toSeq + seq1.indices.foreach { outerIndex => + val list1 = seq1(outerIndex) + val list2 = seq2(outerIndex) + if (list1.size == list2.size) { + val indices1 = list1.indices + dataCount = dataCount + indices1.size + indices1.foreach { innerIndex => + val value1 = list1(innerIndex) + val value2 = list2(innerIndex) + if (value1 * value2 == 0) { + dataCount -= 1 + } else if (value1 > value2) { + dataSum = dataSum - value2.toFloat / value1.toFloat + } else { + dataSum = dataSum + value2.toFloat / value1.toFloat + } + } + } + } + } + if (dataSum != 0) { + math.max(0.15f, math.min(2f, dataSum / dataCount.toFloat)) + } else { + 1f //can't do anything; multiplier should not affect values + } + } + + /** + * na + * @param populationNumbers list of the population updates + * @param gradingRule the rule whereby population numbers are transformed into percentage bonus + * @param layers from largest groupings of percentages from applying the above rule, average the values from this many groups + * @return the modifier value + */ + private[participation] def populationProgressModifier( + populationNumbers: Seq[Int], + gradingRule: Int=>Float, + layers: Int + ): Float = { + val gradedPopulation = populationNumbers + .map { gradingRule } + .groupBy(x => x) + .values + .toSeq + .sortBy(_.size) + .take(layers) + .flatten + gradedPopulation.sum / gradedPopulation.size.toFloat + } + + private[participation] def populationBalanceModifier( + victorPopulationNumbers: Seq[Int], + opposingPopulationNumbers: Seq[Int], + healthyPercentage: Float + ): Float = { + val rate = for { + victorPop <- victorPopulationNumbers + opposePop <- opposingPopulationNumbers + out = if ( + (opposePop + victorPop < 8) || + (opposePop < victorPop && opposePop * healthyPercentage > victorPop) || + (opposePop > victorPop && victorPop * healthyPercentage > opposePop) + ) { + 1f //balanced enough population + } else { + opposePop / victorPop.toFloat + } + if true + } yield out + rate.sum / rate.size.toFloat + } + + private[participation] def competitionBonus( + victorSize: Long, + opposingSize: Long, + steamrollPercentage: Float, + steamrollBonus: Long, + overwhelmingOddsPercentage: Float, + overwhelmingOddsBonus: Long + ): Long = { + if (opposingSize * steamrollPercentage < victorSize.toFloat) { + 0L //steamroll by the victor + } else if (victorSize * overwhelmingOddsPercentage <= opposingSize.toFloat) { + overwhelmingOddsBonus + opposingSize + victorSize //victory against overwhelming odds + } else { + steamrollBonus * opposingSize //still a battle + } + } +} 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 new file mode 100644 index 000000000..8d2d1e099 --- /dev/null +++ b/src/main/scala/net/psforever/objects/serverobject/structures/participation/MajorFacilityHackParticipation.scala @@ -0,0 +1,294 @@ +// Copyright (c) 2023 PSForever +package net.psforever.objects.serverobject.structures.participation + +import net.psforever.objects.serverobject.structures.{Building, StructureType} +import net.psforever.objects.sourcing.{PlayerSource, UniquePlayer} +import net.psforever.objects.zones.{HotSpotInfo, ZoneHotSpotProjector} +import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage} +import net.psforever.types.{PlanetSideEmpire, PlanetSideGeneratorState, Vector3} +import net.psforever.util.Config +import akka.pattern.ask +import akka.util.Timeout +import net.psforever.objects.avatar.scoring.Kill +import net.psforever.objects.zones.exp.ToDatabase + +import scala.collection.mutable +import scala.concurrent.duration._ +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +final case class MajorFacilityHackParticipation(building: Building) extends FacilityHackParticipation { + private implicit val timeout: Timeout = 10.seconds + + private var hotSpotLayersOverTime: Seq[List[HotSpotInfo]] = Seq[List[HotSpotInfo]]() + + def TryUpdate(): Unit = { + val list = building.PlayersInSOI + updatePlayers(list) + val now = System.currentTimeMillis() + if (now - lastInfoRequest > 60000L) { + updatePopulationOverTime(list, now, before = 900000L) + updateHotSpotInfoOverTime() + updateTime(now) + } + lastInfoRequest = now + } + + private def updateHotSpotInfoOnly(): Future[ZoneHotSpotProjector.ExposedHeat] = { + ask( + building.Zone.Activity, + ZoneHotSpotProjector.ExposeHeatForRegion(building.Position, building.Definition.SOIRadius.toFloat) + ).mapTo[ZoneHotSpotProjector.ExposedHeat] + } + + private def updateHotSpotInfoOverTime(): Future[ZoneHotSpotProjector.ExposedHeat] = { + import net.psforever.objects.zones.ZoneHotSpotProjector + + import scala.concurrent.Promise + import scala.util.Success + val requestLayers: Promise[ZoneHotSpotProjector.ExposedHeat] = Promise[ZoneHotSpotProjector.ExposedHeat]() + val request = updateHotSpotInfoOnly() + requestLayers.completeWith(request) + request.onComplete { + case Success(ZoneHotSpotProjector.ExposedHeat(_, _, activity)) => + hotSpotLayersOverTime = timeSensitiveFilterAndAppend(hotSpotLayersOverTime, activity, System.currentTimeMillis() - 900000L) + case _ => + requestLayers.completeWith(Future(ZoneHotSpotProjector.ExposedHeat(Vector3.Zero, 0, Nil))) + } + requestLayers.future + } + + def RewardFacilityCapture( + defenderFaction: PlanetSideEmpire.Value, + attackingFaction: PlanetSideEmpire.Value, + hacker: PlayerSource, + hackTime: Long, + completionTime: Long, + isResecured: Boolean + ): Unit = { + val curr = System.currentTimeMillis() + val hackStart = curr - completionTime + val socketOpt = building.GetFlagSocket + val (victorFaction, opposingFaction, hasFlag, flagCarrier) = if (!isResecured) { + val carrier = socketOpt.flatMap(_.previousFlag).flatMap(_.Carrier) + (attackingFaction, defenderFaction, socketOpt.nonEmpty, carrier) + } else { + (defenderFaction, attackingFaction, socketOpt.nonEmpty, None) + } + val (contributionVictor, contributionOpposing, _) = { + val (a, b1) = playerContribution.partition { case (_, (p, _, _)) => p.Faction == victorFaction } + val (b, c) = b1.partition { case (_, (p, _, _)) => p.Faction == opposingFaction } + (a.values, b.values, c.values) + } + val contributionVictorSize = contributionVictor.size + if (contributionVictorSize > 0) { + //setup for ... + val populationIndices = playerPopulationOverTime.indices + val allFactions = PlanetSideEmpire.values.filterNot { _ == PlanetSideEmpire.NEUTRAL }.toSeq + val (victorPopulationByLayer, opposingPopulationByLayer) = { + val individualPopulationByLayer = allFactions.map { f => + (f, populationIndices.indices.map { i => playerPopulationOverTime(i)(f) }) + }.toMap[PlanetSideEmpire.Value, Seq[Int]] + (individualPopulationByLayer(victorFaction), individualPopulationByLayer(opposingFaction)) + } + val contributionOpposingSize = contributionOpposing.size + val killsByPlayersNotInTower = eliminateClosestTowerFromParticipating( + building, + FacilityHackParticipation.allocateKillsByPlayers( + building.Position, + building.Definition.SOIRadius.toFloat, + hackStart, + completionTime, + opposingFaction, + contributionOpposing + ) + ) + //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( + killsByPlayersNotInTower, + contributionOpposingSize + ) + //2) peak population modifier + //Large facility battles should be well-rewarded. + val populationModifier = FacilityHackParticipation.populationProgressModifier( + opposingPopulationByLayer, + { pop => + if (pop > 75) 0.9f + else if (pop > 59) 0.6f + else if (pop > 29) 0.55f + else if (pop > 25) 0.5f + else 0.45f + }, + 4 + ) + //3) competition multiplier + val competitionMultiplier: Float = { + val populationBalanceModifier: Float = FacilityHackParticipation.populationBalanceModifier( + victorPopulationByLayer, + opposingPopulationByLayer, + healthyPercentage = 1.5f + ) + //compensate for heat + val regionHeatMapProgression = { + /* + transform the different layers of the facility heat map timeline into a progressing timeline of regional hotspot information; + where the grouping are of simultaneous hotspots, + the letter indicates a unique hotspot, + and the number an identifier between related hotspots: + ((A-1, B-2, C-3), (D-1, E-2, F-3), (G-1, H-2, I-3)) ... (1->(A, D, G), 2->(B, E, H), 3->(C, F, I)) + */ + val finalMap = mutable.HashMap[Vector3, Map[PlanetSideEmpire.Value, Seq[Long]]]() + .addAll( + hotSpotLayersOverTime.take(1).flatMap { entry => + entry.map { f => (f.DisplayLocation, Map.empty[PlanetSideEmpire.Value, Seq[Long]]) } + } + ) + //note: this pre-seeding of keys allows us to skip a getOrElse call in the foldLeft + hotSpotLayersOverTime.foldLeft(finalMap) { (map, list) => + list.foreach { entry => + val key = entry.DisplayLocation + val newValues = entry.Activity.map { case (f, e) => (f, e.Heat.toLong) } + val combinedValues = map(key).map { case (f, e) => (f, e :+ newValues(f)) } + map.put(key, combinedValues) + } + map + }.toMap + finalMap //explicit for no good reason + } + val heatMapModifier = FacilityHackParticipation.heatMapComparison( + FacilityHackParticipation.diffHeatForFactionMap(regionHeatMapProgression, victorFaction).values, + FacilityHackParticipation.diffHeatForFactionMap(regionHeatMapProgression, opposingFaction).values + ) + heatMapModifier * populationBalanceModifier + } + //4) hack time modifier + //Captured major facilities without a lattice link unit and resecured major facilities with a lattice link unit + // incur the full hack time if the module is not transported to a friendly facility + //Captured major facilities with a lattice link unit and resecure major facilities without a lattice link uit + // will incur an abbreviated duration + val overallTimeMultiplier: Float = { + if ( + building.Faction == PlanetSideEmpire.NEUTRAL || + building.NtuLevel == 0 || + building.Generator.map { _.Condition }.contains(PlanetSideGeneratorState.Destroyed) + ) { //the facility ran out of nanites or power during the hack or became neutral + 0f + } else if (hasFlag) { + if (completionTime >= hackTime) { //hack timed out without llu delivery + 0.25f + } else if (isResecured) { + 0.5f + (if (hackTime <= completionTime * 0.3f) { + completionTime.toFloat / hackTime.toFloat + } else if (hackTime >= completionTime * 0.6f) { + (hackTime - completionTime).toFloat / hackTime.toFloat + } else { + 0f + }) + } else { + 0.5f + (hackTime - completionTime).toFloat / (2f * hackTime) + } + } else { + if (isResecured) { + 0.5f + (hackTime - completionTime).toFloat / (2f * hackTime) + } else { + 0.5f + } + } + } + //5. individual contribution factors - by time + val contributionPerPlayerByTime = playerContribution.collect { + case (a, (_, d, t)) if d >= 600000 && math.abs(completionTime - t) < 5000 => + (a, 0.45f) + case (a, (_, d, _)) if d >= 600000 => + (a, 0.25f) + case (a, (_, d, t)) if math.abs(completionTime - t) < 5000 => + (a, 0.25f * (0.5f + (d.toFloat / 600000f))) + case (a, (_, _, _)) => + (a, 0.15f) + } + //6. competition bonus + //This value will probably suck, and that's fine. + val competitionBonus: Long = FacilityHackParticipation.competitionBonus( + contributionVictorSize, + contributionOpposingSize, + steamrollPercentage = 1.25f, + steamrollBonus = 5L, + overwhelmingOddsPercentage = 0.5f, + overwhelmingOddsBonus = 15L + ) + //7. calculate overall command experience points + val finalCep: Long = math.ceil( + math.max(0L, baseExperienceFromFacilityCapture) * + populationModifier * + competitionMultiplier * + overallTimeMultiplier * + Config.app.game.experience.cep.rate + competitionBonus + ).toLong + //8. reward participants + //Classically, only players in the SOI are rewarded, and the llu runner too + val hackerId = hacker.CharId + val events = building.Zone.AvatarEvents + val playersInSoi = building.PlayersInSOI + if (playersInSoi.exists(_.CharId == hackerId) && flagCarrier.map(_.CharId).getOrElse(0L) != hackerId) { + events ! AvatarServiceMessage(hacker.Name, AvatarAction.AwardCep(hackerId, finalCep)) + } + playersInSoi + .filter { player => player.Faction == victorFaction && player.CharId != hackerId } + .foreach { player => + val charId = player.CharId + val contributionMultiplier = contributionPerPlayerByTime.getOrElse(charId, 1f) + val outputValue = (finalCep * contributionMultiplier).toLong + events ! AvatarServiceMessage(player.Name, AvatarAction.AwardCep(0, outputValue)) + } + flagCarrier.collect { + case player if !isResecured => + val charId: Long = player.CharId + val finalModifiedCep: Long = { + val durationPoints: Long = (hackTime - completionTime) / 1500L + val betterDurationPoints: Long = if (durationPoints >= 200L) { + durationPoints + } else { + 200L + durationPoints + } + math.min( + betterDurationPoints, + (finalCep * Config.app.game.experience.cep.lluCarrierModifier).toLong + ) + } + ToDatabase.reportFacilityCapture( + charId, + building.Zone.Number, + building.GUID.guid, + finalModifiedCep, + expType="llu" + ) + events ! AvatarServiceMessage(player.Name, AvatarAction.AwardCep(charId, finalModifiedCep)) + } + } + } + + private def eliminateClosestTowerFromParticipating( + building: Building, + list: Iterable[(UniquePlayer, Float, Seq[Kill])] + ): Iterable[(UniquePlayer, Float, Seq[Kill])] = { + val buildingPosition = building.Position.xy + building + .Zone + .Buildings + .values + .filter { building => building.BuildingType == StructureType.Tower } + .minByOption { tower => Vector3.DistanceSquared(buildingPosition, tower.Position.xy) } + .map { tower => + val towerPosition = tower.Position.xy + val towerRadius = math.pow(tower.Definition.SOIRadius.toDouble * 0.7d, 2d).toFloat + list + .map { case (p, f, kills) => + val filteredKills = kills.filter { kill => Vector3.DistanceSquared(kill.victim.Position.xy, towerPosition) <= towerRadius } + (p, f, filteredKills) + } + .filter { case (_, _, kills) => kills.nonEmpty } + } + .getOrElse(list) + } +} diff --git a/src/main/scala/net/psforever/objects/serverobject/structures/participation/NoParticipation.scala b/src/main/scala/net/psforever/objects/serverobject/structures/participation/NoParticipation.scala new file mode 100644 index 000000000..2d758368d --- /dev/null +++ b/src/main/scala/net/psforever/objects/serverobject/structures/participation/NoParticipation.scala @@ -0,0 +1,20 @@ +// Copyright (c) 2023 PSForever +package net.psforever.objects.serverobject.structures.participation + +import net.psforever.objects.serverobject.structures.Building +import net.psforever.objects.sourcing.PlayerSource +import net.psforever.types.PlanetSideEmpire + +case object NoParticipation extends ParticipationLogic { + def building: Building = Building.NoBuilding + def TryUpdate(): Unit = { /* nothing here */ } + def RewardFacilityCapture( + defenderFaction: PlanetSideEmpire.Value, + attackingFaction: PlanetSideEmpire.Value, + hacker: PlayerSource, + hackTime: Long, + completionTime: Long, + isResecured: Boolean + ): Unit = { /* nothing here */ } + override def PlayerContribution(timeDelay: Long): Map[Long, Float] = Map.empty[Long, Float] +} diff --git a/src/main/scala/net/psforever/objects/serverobject/structures/participation/ParticipationLogic.scala b/src/main/scala/net/psforever/objects/serverobject/structures/participation/ParticipationLogic.scala new file mode 100644 index 000000000..8e91e3441 --- /dev/null +++ b/src/main/scala/net/psforever/objects/serverobject/structures/participation/ParticipationLogic.scala @@ -0,0 +1,35 @@ +// Copyright (c) 2023 PSForever +package net.psforever.objects.serverobject.structures.participation + +import net.psforever.objects.serverobject.structures.Building +import net.psforever.objects.sourcing.PlayerSource +import net.psforever.types.PlanetSideEmpire + +//noinspection ScalaUnusedSymbol +trait ParticipationLogic { + def building: Building + def TryUpdate(): Unit + /** + * na + * @param defenderFaction those attempting to stop the hack + * the `terminal` (above) and facility originally belonged to this empire + * @param attackingFaction those attempting to progress the hack; + * the `hacker` (below) belongs to this empire + * @param hacker the player who hacked the capture terminal (above) + * @param hackTime how long the over-all facility hack allows or requires + * @param completionTime how long the facility hacking process lasted + * @param isResecured whether `defendingFaction` or the `attackingFaction` succeeded; + * the latter is called a "capture", + * while the former is a "resecure" + */ + def RewardFacilityCapture( + defenderFaction: PlanetSideEmpire.Value, + attackingFaction: PlanetSideEmpire.Value, + hacker: PlayerSource, + hackTime: Long, + completionTime: Long, + isResecured: Boolean + ): Unit + + def PlayerContribution(timeDelay: Long = 600): Map[Long, Float] +} 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 new file mode 100644 index 000000000..2e2d459c2 --- /dev/null +++ b/src/main/scala/net/psforever/objects/serverobject/structures/participation/TowerHackParticipation.scala @@ -0,0 +1,163 @@ +// Copyright (c) 2023 PSForever +package net.psforever.objects.serverobject.structures.participation + +import net.psforever.objects.serverobject.structures.Building +import net.psforever.objects.sourcing.PlayerSource +import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage} +import net.psforever.types.{PlanetSideEmpire, Vector3} +import net.psforever.util.Config + +final case class TowerHackParticipation(building: Building) extends FacilityHackParticipation { + def TryUpdate(): Unit = { + val list = building.PlayersInSOI + updatePlayers(building.PlayersInSOI) + val now = System.currentTimeMillis() + if (now - lastInfoRequest > 60000L) { + updatePopulationOverTime(list, now, before = 300000L) + } + } + + def RewardFacilityCapture( + defenderFaction: PlanetSideEmpire.Value, + attackingFaction: PlanetSideEmpire.Value, + hacker: PlayerSource, + hackTime: Long, + completionTime: Long, + isResecured: Boolean + ): Unit = { + val (victorFaction, opposingFaction) = if (!isResecured) { + (attackingFaction, defenderFaction) + } else { + (defenderFaction, attackingFaction) + } + val (contributionVictor, contributionOpposing, _) = { + //TODO this is only to preserve a semblance of the original return type; fix this output + val (a, b1) = playerContribution.partition { case (_, (p, _, _)) => p.Faction == victorFaction } + val (b, c) = b1.partition { case (_, (p, _, _)) => p.Faction == opposingFaction } + (a.values, b.values, c.values) + } + val contributionVictorSize = contributionVictor.size + if (contributionVictorSize > 0) { + //setup for ... + import scala.concurrent.duration._ + val curr = System.currentTimeMillis() + val contributionOpposingSize = contributionOpposing.size + val populationIndices = playerPopulationOverTime.indices + val allFactions = PlanetSideEmpire.values.filterNot { + _ == PlanetSideEmpire.NEUTRAL + }.toSeq + val (victorPopulationByLayer, opposingPopulationByLayer) = { + val individualPopulationByLayer = allFactions.map { f => + (f, populationIndices.indices.map { i => playerPopulationOverTime(i)(f) }) + }.toMap[PlanetSideEmpire.Value, Seq[Int]] + (individualPopulationByLayer(victorFaction), individualPopulationByLayer(opposingFaction)) + } + val soiPlayers = building.PlayersInSOI + //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( + FacilityHackParticipation.allocateKillsByPlayers( + building.Position, + building.Definition.SOIRadius.toFloat, + curr - 5.minutes.toMillis, + curr, + opposingFaction, + contributionVictor + ), + contributionOpposingSize + ) + //2) peak population modifier + //Towers should not be regarded as major battles. + //As the population rises, the rewards decrease (dramatically). + val populationModifier = FacilityHackParticipation.populationProgressModifier( + victorPopulationByLayer, + { pop => + if (pop > 80) 0f + else if (pop > 39) (80 - pop).toFloat * 0.01f + else if (pop > 25) 0.5f + else if (pop > 19) 0.55f + else if (pop > 9) 0.6f + else if (pop > 5) 0.75f + else 1f + }, + 2 + ) + //3) competition multiplier + val competitionMultiplier: Float = FacilityHackParticipation.populationBalanceModifier( + victorPopulationByLayer, + opposingPopulationByLayer, + healthyPercentage = 1.25f + ) + //4a. individual contribution factors - by time + //Once again, an arbitrary five minute period. + val contributionPerPlayerByTime = playerContribution.collect { + case (a, (_, d, t)) if d >= 300000 && math.abs(completionTime - t) < 5000 => + (a, 0.45f) + case (a, (_, d, _)) if d >= 300000 => + (a, 0.25f) + case (a, (_, d, t)) if math.abs(completionTime - t) < 5000 => + (a, 0.25f * (0.5f + (d.toFloat / 300000f))) + case (a, (_, _, _)) => + (a, 0.15f) + } + //4b. individual contribution factors - by distance to goal (secondary_capture) + //Because the hack duration of towers is instantaneous, distance from terminal is a more important factor + val contributionPerPlayerByDistanceFromGoal = { + var minDistance: Float = Float.PositiveInfinity + val location = building + .CaptureTerminal + .map { terminal => terminal.Position } + .getOrElse { hacker.Position } + soiPlayers + .map { p => + val distance = Vector3.Distance(p.Position, location) + minDistance = math.min(minDistance, distance) + (p.CharId, distance) + } + .map { case (id, distance) => + (id, math.max(0.15f, minDistance / distance)) + } + }.toMap[Long, Float] + //5) token competition bonus + //This value will probably suck, and that's fine. + val competitionBonus: Long = FacilityHackParticipation.competitionBonus( + contributionVictorSize, + contributionOpposingSize, + steamrollPercentage = 1.25f, + steamrollBonus = 2L, + overwhelmingOddsPercentage = 0.5f, + overwhelmingOddsBonus = 30L + ) + //6. calculate overall command experience points + val finalCep: Long = math.ceil( + baseExperienceFromFacilityCapture * + populationModifier * + competitionMultiplier * + Config.app.game.experience.cep.rate + competitionBonus + ).toLong + //7. reward participants + //Classically, only players in the SOI are rewarded + val events = building.Zone.AvatarEvents + soiPlayers + .filter { player => + player.Faction == victorFaction && player.CharId != hacker.CharId + } + .foreach { player => + val charId = player.CharId + val contributionTimeMultiplier = contributionPerPlayerByTime.getOrElse(charId, 0.5f) + val contributionDistanceMultiplier = contributionPerPlayerByDistanceFromGoal.getOrElse(charId, 0.5f) + val outputValue = (finalCep * contributionTimeMultiplier * contributionDistanceMultiplier).toLong + events ! AvatarServiceMessage( + player.Name, + AvatarAction.AwardCep(0, outputValue) + ) + } + events ! AvatarServiceMessage(hacker.Name, AvatarAction.AwardCep(hacker.CharId, finalCep)) + } + + playerContribution.clear() + playerPopulationOverTime.reverse match { + case entry :: _ => playerPopulationOverTime = Seq(entry) + } + } +} diff --git a/src/main/scala/net/psforever/objects/serverobject/terminals/ProximityTerminalControl.scala b/src/main/scala/net/psforever/objects/serverobject/terminals/ProximityTerminalControl.scala index b2af1fe73..3ab229af5 100644 --- a/src/main/scala/net/psforever/objects/serverobject/terminals/ProximityTerminalControl.scala +++ b/src/main/scala/net/psforever/objects/serverobject/terminals/ProximityTerminalControl.scala @@ -17,7 +17,7 @@ import net.psforever.objects.serverobject.damage.DamageableAmenity import net.psforever.objects.serverobject.hackable.{GenericHackables, HackableBehavior} import net.psforever.objects.serverobject.repair.{AmenityAutoRepair, RepairableAmenity} import net.psforever.objects.serverobject.structures.{Building, PoweredAmenityControl} -import net.psforever.objects.vital.{HealFromTerm, RepairFromTerm, Vitality} +import net.psforever.objects.vital.{HealFromTerminal, RepairFromTerminal, Vitality} import net.psforever.objects.zones.ZoneAware import net.psforever.packet.game.InventoryStateMessage import net.psforever.services.Service @@ -291,7 +291,7 @@ object ProximityTerminalControl { healAmount } target.Health = health + finalHealthAmount - target.LogActivity(HealFromTerm(AmenitySource(terminal), finalHealthAmount)) + target.LogActivity(HealFromTerminal(AmenitySource(terminal), finalHealthAmount)) updateFunc(target) target.Health == maxHealth } else { @@ -338,7 +338,7 @@ object ProximityTerminalControl { repairAmount } target.Armor = armor + finalRepairAmount - target.LogActivity(RepairFromTerm(AmenitySource(terminal), finalRepairAmount)) + target.LogActivity(RepairFromTerminal(AmenitySource(terminal), finalRepairAmount)) val zone = target.Zone zone.AvatarEvents ! AvatarServiceMessage( zone.id, diff --git a/src/main/scala/net/psforever/objects/serverobject/turret/FacilityTurretControl.scala b/src/main/scala/net/psforever/objects/serverobject/turret/FacilityTurretControl.scala index c3ab7e93f..ff61eee36 100644 --- a/src/main/scala/net/psforever/objects/serverobject/turret/FacilityTurretControl.scala +++ b/src/main/scala/net/psforever/objects/serverobject/turret/FacilityTurretControl.scala @@ -101,7 +101,7 @@ class FacilityTurretControl(turret: FacilityTurret) turret.ControlledWeapon(wepNumber = 1).foreach { case weapon: Tool => // recharge when last shot fired 3s delay, +1, 200ms interval - if (weapon.Magazine < weapon.MaxMagazine && System.nanoTime() - weapon.LastDischarge > 3000000000L) { + if (weapon.Magazine < weapon.MaxMagazine && System.currentTimeMillis() - weapon.LastDischarge > 3000L) { weapon.Magazine += 1 val seat = turret.Seat(0).get seat.occupant match { diff --git a/src/main/scala/net/psforever/objects/serverobject/turret/TurretDefinition.scala b/src/main/scala/net/psforever/objects/serverobject/turret/TurretDefinition.scala index 9936abab3..ecb84ac5e 100644 --- a/src/main/scala/net/psforever/objects/serverobject/turret/TurretDefinition.scala +++ b/src/main/scala/net/psforever/objects/serverobject/turret/TurretDefinition.scala @@ -1,7 +1,7 @@ // Copyright (c) 2017 PSForever package net.psforever.objects.serverobject.turret -import net.psforever.objects.definition.{ObjectDefinition, ToolDefinition} +import net.psforever.objects.definition.{ObjectDefinition, ToolDefinition, WithShields} import net.psforever.objects.vehicles.{MountableWeaponsDefinition, Turrets} import net.psforever.objects.vital.resistance.ResistanceProfileMutators import net.psforever.objects.vital.resolution.DamageResistanceModel @@ -14,7 +14,8 @@ import scala.collection.mutable trait TurretDefinition extends MountableWeaponsDefinition with ResistanceProfileMutators - with DamageResistanceModel { + with DamageResistanceModel + with WithShields { odef: ObjectDefinition => Turrets(odef.ObjectId) //let throw NoSuchElementException /* key - upgrade, value - weapon definition */ diff --git a/src/main/scala/net/psforever/objects/sourcing/AmenitySource.scala b/src/main/scala/net/psforever/objects/sourcing/AmenitySource.scala index 56e9b600f..23893df77 100644 --- a/src/main/scala/net/psforever/objects/sourcing/AmenitySource.scala +++ b/src/main/scala/net/psforever/objects/sourcing/AmenitySource.scala @@ -17,6 +17,7 @@ final case class AmenitySource( health: Int, Orientation: Vector3, occupants: List[SourceEntry], + installation: SourceEntry, hacked: Option[HackInfo], unique: UniqueAmenity ) extends SourceWithHealthEntry { @@ -54,6 +55,7 @@ object AmenitySource { health, obj.Orientation, Nil, + SourceEntry(obj.Owner), hackData, sourcing.UniqueAmenity(obj.Zone.Number, obj.GUID, obj.Position) ) diff --git a/src/main/scala/net/psforever/objects/sourcing/PlayerSource.scala b/src/main/scala/net/psforever/objects/sourcing/PlayerSource.scala index 1e2874201..a291d7b8d 100644 --- a/src/main/scala/net/psforever/objects/sourcing/PlayerSource.scala +++ b/src/main/scala/net/psforever/objects/sourcing/PlayerSource.scala @@ -1,6 +1,7 @@ // Copyright (c) 2017 PSForever package net.psforever.objects.sourcing +import net.psforever.objects.avatar.scoring.Life import net.psforever.objects.definition.{AvatarDefinition, ExoSuitDefinition} import net.psforever.objects.serverobject.affinity.FactionAffinity import net.psforever.objects.serverobject.mount.Mountable @@ -28,7 +29,7 @@ final case class PlayerSource( jumping: Boolean, Modifiers: ResistanceProfile, bep: Long, - kills: Seq[Any], + progress: Life, unique: UniquePlayer ) extends SourceWithHealthEntry { override def Name: String = unique.name @@ -46,6 +47,7 @@ object PlayerSource { val exosuit = p.ExoSuit val faction = p.Faction val seatedEntity = mountableAndSeat(p) + val avatar = p.avatar PlayerSource( p.Definition, exosuit, @@ -58,13 +60,17 @@ object PlayerSource { p.Crouching, p.Jumping, ExoSuitDefinition.Select(exosuit, faction), - p.avatar.bep, - kills = Nil, + avatar.bep, + progress = avatar.scorecard.CurrentLife, UniquePlayer(p.CharId, p.Name, p.Sex, faction) ) } def apply(name: String, faction: PlanetSideEmpire.Value, position: Vector3): PlayerSource = { + this(UniquePlayer(0L, name, CharacterSex.Male, faction), position) + } + + def apply(unique: UniquePlayer, position: Vector3): PlayerSource = { new PlayerSource( GlobalDefinitions.avatar, ExoSuitType.Standard, @@ -78,8 +84,8 @@ object PlayerSource { jumping = false, GlobalDefinitions.Standard, bep = 0L, - kills = Nil, - UniquePlayer(0L, name, CharacterSex.Male, faction) + progress = tokenLife, + unique ) } @@ -116,6 +122,7 @@ object PlayerSource { def inSeat(player: Player, source: SourceEntry, seatNumber: Int): PlayerSource = { val exosuit = player.ExoSuit val faction = player.Faction + val avatar = player.avatar PlayerSource( player.Definition, exosuit, @@ -128,12 +135,25 @@ object PlayerSource { player.Crouching, player.Jumping, ExoSuitDefinition.Select(exosuit, faction), - player.avatar.bep, - kills = Nil, + avatar.bep, + progress = tokenLife, UniquePlayer(player.CharId, player.Name, player.Sex, faction) ) } + /** + * Produce a copy of a normal player source entity + * but the `seatedIn` field is overrode to point at the specified vehicle and seat number.
+ * Don't think too much about it. + * @param player `SourceEntry` for a player + * @param source `SourceEntry` for the aforementioned mountable entity + * @param seatNumber the attributed seating index in which the player is mounted in `source` + * @return a `PlayerSource` entity + */ + def inSeat(player: PlayerSource, source: SourceEntry, seatNumber: Int): PlayerSource = { + player.copy(seatedIn = Some((source, seatNumber))) + } + /** * "Nobody is my name: Nobody they call me – * my mother and my father and all my other companions” @@ -142,4 +162,9 @@ object PlayerSource { * the others first: this will be my guest-gift to you.” */ final val Nobody = PlayerSource("Nobody", PlanetSideEmpire.NEUTRAL, Vector3.Zero) + + /** + * Used to dummy the statistics value for shallow player source entities. + */ + private val tokenLife: Life = Life() } diff --git a/src/main/scala/net/psforever/objects/sourcing/VehicleSource.scala b/src/main/scala/net/psforever/objects/sourcing/VehicleSource.scala index fc02bdf1c..7ff45cfd4 100644 --- a/src/main/scala/net/psforever/objects/sourcing/VehicleSource.scala +++ b/src/main/scala/net/psforever/objects/sourcing/VehicleSource.scala @@ -17,14 +17,15 @@ final case class VehicleSource( Orientation: Vector3, Velocity: Option[Vector3], deployed: DriveState.Value, + owner: Option[UniquePlayer], occupants: List[SourceEntry], Modifiers: ResistanceProfile, unique: UniqueVehicle ) extends SourceWithHealthEntry with SourceWithShieldsEntry { - def Name: String = SourceEntry.NameFormat(Definition.Name) - def Health: Int = health - def Shields: Int = shields - def total: Int = health + shields + def Name: String = SourceEntry.NameFormat(Definition.Name) + def Health: Int = health + def Shields: Int = shields + def total: Int = health + shields } object VehicleSource { @@ -42,6 +43,7 @@ object VehicleSource { obj.Orientation, obj.Velocity, obj.DeploymentState, + None, Nil, obj.Definition.asInstanceOf[ResistanceProfile], UniqueVehicle( @@ -52,13 +54,15 @@ object VehicleSource { obj.OriginalOwnerName.getOrElse("none") ) ) - vehicle.copy(occupants = { - obj.Seats.map { case (seatNumber, seat) => + //shallow information that references the existing source entry + vehicle.copy( + owner = obj.Owners, + occupants = obj.Seats.map { case (seatNumber, seat) => seat.occupant match { - case Some(p) => PlayerSource.inSeat(p, vehicle, seatNumber) //shallow + case Some(p) => PlayerSource.inSeat(p, vehicle, seatNumber) case _ => PlayerSource.Nobody } }.toList - }) + ) } } diff --git a/src/main/scala/net/psforever/objects/vehicles/AntTransferBehavior.scala b/src/main/scala/net/psforever/objects/vehicles/AntTransferBehavior.scala index d1655265c..97d6b8576 100644 --- a/src/main/scala/net/psforever/objects/vehicles/AntTransferBehavior.scala +++ b/src/main/scala/net/psforever/objects/vehicles/AntTransferBehavior.scala @@ -1,7 +1,7 @@ // Copyright (c) 2020 PSForever package net.psforever.objects.vehicles -import akka.actor.ActorRef +import akka.actor.{ActorRef, Cancellable} import net.psforever.actors.commands.NtuCommand import net.psforever.actors.zone.BuildingActor import net.psforever.objects.serverobject.deploy.Deployment @@ -13,17 +13,18 @@ import net.psforever.types.DriveState import net.psforever.services.Service import net.psforever.services.vehicle.{VehicleAction, VehicleServiceMessage} import akka.actor.typed.scaladsl.adapter._ +import net.psforever.objects.serverobject.transfer.TransferContainer.TransferMaterial import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.duration._ trait AntTransferBehavior extends TransferBehavior with NtuStorageBehavior { var panelAnimationFunc: () => Unit = NoCharge - var ntuChargingTick = Default.Cancellable + var ntuChargingTick: Cancellable = Default.Cancellable findChargeTargetFunc = Vehicles.FindANTChargingSource findDischargeTargetFunc = Vehicles.FindANTDischargingTarget - def TransferMaterial = Ntu.Nanites + def TransferMaterial: TransferMaterial = Ntu.Nanites def ChargeTransferObject: Vehicle with NtuContainer @@ -186,8 +187,10 @@ trait AntTransferBehavior extends TransferBehavior with NtuStorageBehavior { val chargeToDeposit = if (min == 0) { transferTarget match { case Some(silo: ResourceSilo) => - // Silos would charge from 0-100% in roughly 105s on live (~20%-100% https://youtu.be/veOWToR2nSk?t=1402) - scala.math.min(scala.math.min(silo.MaxNtuCapacitor / 105, chargeable.NtuCapacitor), max) + scala.math.min( + scala.math.min(silo.MaxNtuCapacitor / silo.Definition.ChargeTime.toMillis.toFloat, chargeable.NtuCapacitor), + max + ) case _ => 0 } diff --git a/src/main/scala/net/psforever/objects/vehicles/CarrierBehavior.scala b/src/main/scala/net/psforever/objects/vehicles/CarrierBehavior.scala index ee71b68d4..a621917fc 100644 --- a/src/main/scala/net/psforever/objects/vehicles/CarrierBehavior.scala +++ b/src/main/scala/net/psforever/objects/vehicles/CarrierBehavior.scala @@ -5,12 +5,9 @@ import akka.actor.{Actor, Cancellable} import net.psforever.actors.zone.ZoneActor import net.psforever.objects.zones.Zone import net.psforever.objects._ -import net.psforever.packet.game.{ - CargoMountPointStatusMessage, - ObjectAttachMessage, - ObjectDetachMessage, - PlanetsideAttributeMessage -} +import net.psforever.objects.sourcing.VehicleSource +import net.psforever.objects.vital.VehicleCargoMountActivity +import net.psforever.packet.game.{CargoMountPointStatusMessage, ObjectAttachMessage, ObjectDetachMessage, PlanetsideAttributeMessage} import net.psforever.types.{BailType, CargoStatus, PlanetSideGUID, Vector3} import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage} import net.psforever.services.Service @@ -35,14 +32,12 @@ trait CarrierBehavior { cargoDismountTimer.cancel() val obj = CarrierObject val zone = obj.Zone - zone.GUID(isMounting) match { - case Some(v : Vehicle) => v.Actor ! CargoBehavior.EndCargoMounting(obj.GUID) - case _ => ; + zone.GUID(isMounting).collect { + case v : Vehicle => v.Actor ! CargoBehavior.EndCargoMounting(obj.GUID) } isMounting = None - zone.GUID(isDismounting) match { - case Some(v : Vehicle) => v.Actor ! CargoBehavior.EndCargoDismounting(obj.GUID) - case _ => ; + zone.GUID(isDismounting).collect { + case v : Vehicle => v.Actor ! CargoBehavior.EndCargoDismounting(obj.GUID) } isDismounting = None } @@ -89,9 +84,8 @@ trait CarrierBehavior { ) } else { - obj.Zone.GUID(isMounting) match { - case Some(v: Vehicle) => v.Actor ! CargoBehavior.EndCargoMounting(obj.GUID) - case _ => ; + obj.Zone.GUID(isMounting).collect { + case v: Vehicle => v.Actor ! CargoBehavior.EndCargoMounting(obj.GUID) } isMounting = None } @@ -115,10 +109,9 @@ trait CarrierBehavior { kicked = false ) case _ => - obj.CargoHold(mountPoint) match { - case Some(hold) if hold.isOccupied && hold.occupant.get.GUID == cargo_guid => - hold.unmount(hold.occupant.get) - case _ => ; + obj.CargoHold(mountPoint).collect { + case hold if hold.isOccupied && hold.occupant.get.GUID == cargo_guid => + CarrierBehavior.CargoDismountAction(obj, hold.occupant.get, hold, BailType.Normal) } false } @@ -135,17 +128,15 @@ trait CarrierBehavior { CarrierBehavior.CheckCargoDismount(cargo_guid, mountPoint, iteration + 1, bailed) ) } else { - zone.GUID(isDismounting.getOrElse(cargo_guid)) match { - case Some(cargo: Vehicle) => + zone.GUID(isDismounting.getOrElse(cargo_guid)).collect { + case cargo: Vehicle => cargo.Actor ! CargoBehavior.EndCargoDismounting(guid) - case _ => ; } isDismounting = None } } else { - zone.GUID(isDismounting.getOrElse(cargo_guid)) match { - case Some(cargo: Vehicle) => cargo.Actor ! CargoBehavior.EndCargoDismounting(guid) - case _ => ; + zone.GUID(isDismounting.getOrElse(cargo_guid)).collect { + case cargo: Vehicle => cargo.Actor ! CargoBehavior.EndCargoDismounting(guid) } isDismounting = None } @@ -213,10 +204,8 @@ object CarrierBehavior { if (distance <= 64) { //cargo vehicle is close enough to assume to be physically within the carrier's hold; mount it log.debug(s"HandleCheckCargoMounting: mounting cargo vehicle in carrier at distance of $distance") - hold.mount(cargo) - cargo.MountedIn = carrierGUID + CargoMountAction(carrier, cargo, hold, carrierGUID) cargo.Velocity = None - cargo.Actor ! CargoBehavior.EndCargoMounting(carrierGUID) zone.VehicleEvents ! VehicleServiceMessage( s"${cargo.Actor}", VehicleAction.SendResponse(PlanetSideGUID(0), PlanetsideAttributeMessage(cargoGUID, 0, cargo.Health)) @@ -358,9 +347,7 @@ object CarrierBehavior { //obviously, don't do this } else if (iteration > 40) { //cargo vehicle has spent too long not getting far enough away; restore the cargo's mount in the carrier hold - hold.mount(cargo) - cargo.MountedIn = carrierGUID - cargo.Actor ! CargoBehavior.EndCargoMounting(carrierGUID) + CargoMountAction(carrier, cargo, hold, carrierGUID) CargoMountBehaviorForAll(carrier, cargo, mountPoint) zone.actor ! ZoneActor.RemoveFromBlockMap(cargo) false @@ -441,9 +428,10 @@ object CarrierBehavior { val zone = carrier.Zone carrier.CargoHolds.find({ case (_, hold) => hold.occupant.contains(cargo) }) match { case Some((mountPoint, hold)) => - cargo.MountedIn = None - hold.unmount( + CarrierBehavior.CargoDismountAction( + carrier, cargo, + hold, if (bailed) BailType.Bailed else if (kicked) BailType.Kicked else BailType.Normal ) val driverOpt = cargo.Seats(0).occupant @@ -532,7 +520,7 @@ object CarrierBehavior { targetGUID: PlanetSideGUID ): Unit = { target match { - case Some(_: Vehicle) => ; + case Some(_: Vehicle) => () case Some(_) => log.error(s"$decorator target $targetGUID no longer identifies as a vehicle") case None => log.error(s"$decorator target $targetGUID has gone missing") } @@ -653,4 +641,62 @@ object CarrierBehavior { ) msgs } + + /** + * na + * @param carrier the ferrying vehicle + * @param cargo the ferried vehicle + * @param hold na + * @param carrierGuid the ferrying vehicle's unique identifier + */ + private def CargoMountAction( + carrier: Vehicle, + cargo: Vehicle, + hold: Cargo, + carrierGuid: PlanetSideGUID): Unit = { + hold.mount(cargo) + cargo.MountedIn = carrierGuid + val event = VehicleCargoMountActivity(VehicleSource(carrier), VehicleSource(cargo), carrier.Zone.Number) + cargo.LogActivity(event) + cargo.Seats + .filterNot(_._1 == 0) /*ignore driver*/ + .values + .collect { + case seat if seat.isOccupied => + seat.occupants.foreach { player => + player.LogActivity(event) + } + } + cargo.Actor ! CargoBehavior.EndCargoMounting(carrierGuid) + } + + /** + * na + * @param carrier the ferrying vehicle + * @param cargo the ferried vehicle + * @param hold na + * @param bailType na + */ + private def CargoDismountAction( + carrier: Vehicle, + cargo: Vehicle, + hold: Cargo, + bailType: BailType.Value + ): Unit = { + cargo.MountedIn = None + hold.unmount(cargo, bailType) + val event = VehicleCargoMountActivity(VehicleSource(carrier), VehicleSource(cargo), carrier.Zone.Number) + cargo.LogActivity(event) + cargo.Seats + .filterNot(_._1 == 0) /*ignore driver*/ + .values + .collect { + case seat if seat.isOccupied => + seat.occupants.foreach { player => + player.LogActivity(event) + player.ContributionFrom(cargo) + player.ContributionFrom(carrier) + } + } + } } diff --git a/src/main/scala/net/psforever/objects/vehicles/control/BfrControl.scala b/src/main/scala/net/psforever/objects/vehicles/control/BfrControl.scala index 402f9cd32..69d60d4b1 100644 --- a/src/main/scala/net/psforever/objects/vehicles/control/BfrControl.scala +++ b/src/main/scala/net/psforever/objects/vehicles/control/BfrControl.scala @@ -170,8 +170,8 @@ class BfrControl(vehicle: Vehicle) specialArmWeaponEquipManagement(item, slot, handiness) } - override def dismountCleanup(seatBeingDismounted: Int): Unit = { - super.dismountCleanup(seatBeingDismounted) + override def dismountCleanup(seatBeingDismounted: Int, player: Player): Unit = { + super.dismountCleanup(seatBeingDismounted, player) if (!vehicle.Seats.values.exists(_.isOccupied)) { vehicle.Subsystems(VehicleSubsystemEntry.BattleframeShieldGenerator) match { case Some(subsys) => diff --git a/src/main/scala/net/psforever/objects/vehicles/control/DeployingVehicleControl.scala b/src/main/scala/net/psforever/objects/vehicles/control/DeployingVehicleControl.scala index 445782021..4c33ec918 100644 --- a/src/main/scala/net/psforever/objects/vehicles/control/DeployingVehicleControl.scala +++ b/src/main/scala/net/psforever/objects/vehicles/control/DeployingVehicleControl.scala @@ -34,9 +34,9 @@ class DeployingVehicleControl(vehicle: Vehicle) case msg : Deployment.TryUndeploy => deployBehavior.apply(msg) - case msg @ Mountable.TryDismount(_, seat_num, _) => + case msg @ Mountable.TryDismount(player, seat_num, _) => dismountBehavior.apply(msg) - dismountCleanup(seat_num) + dismountCleanup(seat_num, player) } /** diff --git a/src/main/scala/net/psforever/objects/vehicles/control/VehicleControl.scala b/src/main/scala/net/psforever/objects/vehicles/control/VehicleControl.scala index d79b75f8a..18a3d9797 100644 --- a/src/main/scala/net/psforever/objects/vehicles/control/VehicleControl.scala +++ b/src/main/scala/net/psforever/objects/vehicles/control/VehicleControl.scala @@ -20,10 +20,10 @@ import net.psforever.objects.serverobject.hackable.GenericHackables import net.psforever.objects.serverobject.mount.{Mountable, MountableBehavior} import net.psforever.objects.serverobject.repair.RepairableVehicle import net.psforever.objects.serverobject.terminals.Terminal -import net.psforever.objects.sourcing.{SourceEntry, VehicleSource} +import net.psforever.objects.sourcing.{PlayerSource, SourceEntry, VehicleSource} import net.psforever.objects.vehicles._ import net.psforever.objects.vital.interaction.{DamageInteraction, DamageResult} -import net.psforever.objects.vital.{DamagingActivity, InGameActivity, ShieldCharge} +import net.psforever.objects.vital.{DamagingActivity, InGameActivity, ShieldCharge, VehicleDismountActivity, VehicleMountActivity} import net.psforever.objects.vital.environment.EnvironmentReason import net.psforever.objects.vital.etc.SuicideReason import net.psforever.objects.zones._ @@ -128,9 +128,9 @@ class VehicleControl(vehicle: Vehicle) mountBehavior.apply(msg) mountCleanup(mount_point, player) - case msg @ Mountable.TryDismount(_, seat_num, _) => + case msg @ Mountable.TryDismount(player, seat_num, _) => dismountBehavior.apply(msg) - dismountCleanup(seat_num) + dismountCleanup(seat_num, player) case CommonMessages.ChargeShields(amount, motivator) => chargeShields(amount, motivator.collect { case o: PlanetSideGameObject with FactionAffinity => SourceEntry(o) }) @@ -237,9 +237,9 @@ class VehicleControl(vehicle: Vehicle) def commonDisabledBehavior: Receive = checkBehavior .orElse { - case msg @ Mountable.TryDismount(_, seat_num, _) => + case msg @ Mountable.TryDismount(user, seat_num, _) => dismountBehavior.apply(msg) - dismountCleanup(seat_num) + dismountCleanup(seat_num, user) case Vehicle.Deconstruct(time) => time match { @@ -286,7 +286,7 @@ class VehicleControl(vehicle: Vehicle) val seatGroup = vehicle.SeatPermissionGroup(seatNumber).getOrElse(AccessPermissionGroup.Passenger) val permission = vehicle.PermissionGroup(seatGroup.id).getOrElse(VehicleLockState.Empire) (if (seatGroup == AccessPermissionGroup.Driver) { - vehicle.Owner.contains(user.GUID) || vehicle.Owner.isEmpty || permission != VehicleLockState.Locked + vehicle.OwnerGuid.contains(user.GUID) || vehicle.OwnerGuid.isEmpty || permission != VehicleLockState.Locked } else { permission != VehicleLockState.Locked }) && @@ -297,6 +297,8 @@ class VehicleControl(vehicle: Vehicle) val obj = MountableObject obj.PassengerInSeat(user) match { case Some(seatNumber) => + val vsrc = VehicleSource(vehicle) + user.LogActivity(VehicleMountActivity(vsrc, PlayerSource.inSeat(user, vsrc, seatNumber), vehicle.Zone.Number)) //if the driver mount, change ownership if that is permissible for this vehicle if (seatNumber == 0 && !obj.OwnerName.contains(user.Name) && obj.Definition.CanBeOwned.nonEmpty) { //whatever vehicle was previously owned @@ -325,7 +327,7 @@ class VehicleControl(vehicle: Vehicle) vehicle.DeploymentState == DriveState.Deployed || super.dismountTest(obj, seatNumber, user) } - def dismountCleanup(seatBeingDismounted: Int): Unit = { + def dismountCleanup(seatBeingDismounted: Int, user: Player): Unit = { val obj = MountableObject // Reset velocity to zero when driver dismounts, to allow jacking/repair if vehicle was moving slightly before dismount if (!obj.Seats(0).isOccupied) { @@ -340,6 +342,7 @@ class VehicleControl(vehicle: Vehicle) ) } if (!obj.Seats(seatBeingDismounted).isOccupied) { //seat was vacated + user.LogActivity(VehicleDismountActivity(VehicleSource(vehicle), PlayerSource(user), vehicle.Zone.Number)) //we were only owning the vehicle while we sat in its driver seat val canBeOwned = obj.Definition.CanBeOwned if (canBeOwned.contains(false) && seatBeingDismounted == 0) { @@ -348,7 +351,7 @@ class VehicleControl(vehicle: Vehicle) //are we already decaying? are we unowned? is no one seated anywhere? if (!decaying && obj.Definition.undergoesDecay && - obj.Owner.isEmpty && + obj.OwnerGuid.isEmpty && obj.Seats.values.forall(!_.isOccupied)) { decaying = true decayTimer = context.system.scheduler.scheduleOnce( @@ -418,7 +421,7 @@ class VehicleControl(vehicle: Vehicle) Vehicles.Disown(obj.GUID, obj) if (!decaying && obj.Definition.undergoesDecay && - obj.Owner.isEmpty && + obj.OwnerGuid.isEmpty && obj.Seats.values.forall(!_.isOccupied)) { decaying = true decayTimer = context.system.scheduler.scheduleOnce( @@ -890,7 +893,7 @@ object VehicleControl { /** * Determine if a given activity entry would invalidate the act of charging vehicle shields this tick. - * @param now the current time (in nanoseconds) + * @param now the current time (in milliseconds) * @param act a `VitalsActivity` entry to test * @return `true`, if the shield charge would be blocked; * `false`, otherwise diff --git a/src/main/scala/net/psforever/objects/vital/InGameHistory.scala b/src/main/scala/net/psforever/objects/vital/InGameHistory.scala index 2cc7f603d..eebc44eca 100644 --- a/src/main/scala/net/psforever/objects/vital/InGameHistory.scala +++ b/src/main/scala/net/psforever/objects/vital/InGameHistory.scala @@ -4,12 +4,15 @@ package net.psforever.objects.vital import net.psforever.objects.PlanetSideGameObject import net.psforever.objects.definition.{EquipmentDefinition, KitDefinition, ToolDefinition} import net.psforever.objects.serverobject.affinity.FactionAffinity -import net.psforever.objects.sourcing.{AmenitySource, ObjectSource, PlayerSource, SourceEntry, SourceWithHealthEntry, VehicleSource} +import net.psforever.objects.sourcing.{AmenitySource, PlayerSource, SourceEntry, SourceUniqueness, SourceWithHealthEntry, VehicleSource} import net.psforever.objects.vital.environment.EnvironmentReason import net.psforever.objects.vital.etc.{ExplodingEntityReason, PainboxReason, SuicideReason} import net.psforever.objects.vital.interaction.{DamageInteraction, DamageResult} import net.psforever.objects.vital.projectile.ProjectileReason import net.psforever.types.{ExoSuitType, ImplantType, TransactionType} +import net.psforever.util.Config + +import scala.collection.mutable /* root */ @@ -18,7 +21,21 @@ import net.psforever.types.{ExoSuitType, ImplantType, TransactionType} * Must keep track of the time (ms) the activity occurred. */ trait InGameActivity { - val time: Long = System.currentTimeMillis() + private var _time: Long = System.currentTimeMillis() + + def time: Long = _time +} + +object InGameActivity { + def ShareTime(benefactor: InGameActivity, donor: InGameActivity): InGameActivity = { + benefactor._time = donor.time + benefactor + } + + def SetTime(benefactor: InGameActivity, time: Long): InGameActivity = { + benefactor._time = time + benefactor + } } /* normal history */ @@ -28,13 +45,64 @@ trait InGameActivity { */ trait GeneralActivity extends InGameActivity -final case class SpawningActivity(src: SourceEntry, zoneNumber: Int, unit: Option[SourceEntry]) extends GeneralActivity +trait SupportActivityCausedByAnother { + def user: PlayerSource + def amount: Int +} -final case class ReconstructionActivity(src: SourceEntry, zoneNumber: Int, unit: Option[SourceEntry]) extends GeneralActivity +final case class SpawningActivity(src: SourceEntry, zoneNumber: Int, unit: Option[SourceEntry]) + extends GeneralActivity -final case class ShieldCharge(amount: Int, cause: Option[SourceEntry]) extends GeneralActivity +final case class ReconstructionActivity(src: SourceEntry, zoneNumber: Int, unit: Option[SourceEntry]) + extends GeneralActivity -final case class TerminalUsedActivity(terminal: AmenitySource, transaction: TransactionType.Value) extends GeneralActivity +final case class RevivingActivity(target: SourceEntry, user: PlayerSource, amount: Int, equipment: EquipmentDefinition) + extends GeneralActivity with SupportActivityCausedByAnother + +final case class ShieldCharge(amount: Int, cause: Option[SourceEntry]) + extends GeneralActivity + +final case class TerminalUsedActivity(terminal: AmenitySource, transaction: TransactionType.Value) + extends GeneralActivity + +sealed trait VehicleMountChange extends GeneralActivity { + def vehicle: VehicleSource + def zoneNumber: Int +} + +sealed trait VehiclePassengerMountChange extends VehicleMountChange { + def player: PlayerSource +} + +sealed trait VehicleCargoMountChange extends VehicleMountChange { + def cargo: VehicleSource +} + +final case class VehicleMountActivity(vehicle: VehicleSource, player: PlayerSource, zoneNumber: Int) + extends VehiclePassengerMountChange + +final case class VehicleDismountActivity( + vehicle: VehicleSource, + player: PlayerSource, + zoneNumber: Int, + pairedEvent: Option[VehicleMountActivity] = None + ) extends VehiclePassengerMountChange + +final case class VehicleCargoMountActivity(vehicle: VehicleSource, cargo: VehicleSource, zoneNumber: Int) + extends VehicleCargoMountChange + +final case class VehicleCargoDismountActivity( + vehicle: VehicleSource, + cargo: VehicleSource, + zoneNumber: Int, + pairedEvent: Option[VehicleCargoMountActivity] = None + ) extends VehicleCargoMountChange + +final case class Contribution(src: SourceUniqueness, entries: List[InGameActivity]) + extends GeneralActivity { + val start: Long = entries.headOption.map { _.time }.getOrElse(System.currentTimeMillis()) + val end: Long = entries.lastOption.map { _.time }.getOrElse(start) +} /* vitals history */ @@ -65,9 +133,8 @@ trait DamagingActivity extends VitalsActivity { def health: Int = { (data.targetBefore, data.targetAfter) match { - case (pb: PlayerSource, pa: PlayerSource) if pb.ExoSuit == ExoSuitType.MAX => pb.total - pa.total - case (pb: SourceWithHealthEntry, pa: SourceWithHealthEntry) => pb.health - pa.health - case _ => 0 + case (pb: SourceWithHealthEntry, pa: SourceWithHealthEntry) => pb.health - pa.health + case _ => 0 } } } @@ -76,9 +143,9 @@ final case class HealFromKit(kit_def: KitDefinition, amount: Int) extends HealingActivity final case class HealFromEquipment(user: PlayerSource, equipment_def: EquipmentDefinition, amount: Int) - extends HealingActivity + extends HealingActivity with SupportActivityCausedByAnother -final case class HealFromTerm(term: AmenitySource, amount: Int) +final case class HealFromTerminal(term: AmenitySource, amount: Int) extends HealingActivity final case class HealFromImplant(implant: ImplantType, amount: Int) @@ -91,9 +158,9 @@ final case class RepairFromKit(kit_def: KitDefinition, amount: Int) extends RepairingActivity() final case class RepairFromEquipment(user: PlayerSource, equipment_def: EquipmentDefinition, amount: Int) - extends RepairingActivity + extends RepairingActivity with SupportActivityCausedByAnother -final case class RepairFromTerm(term: AmenitySource, amount: Int) extends RepairingActivity +final case class RepairFromTerminal(term: AmenitySource, amount: Int) extends RepairingActivity final case class RepairFromArmorSiphon(siphon_def: ToolDefinition, vehicle: VehicleSource, amount: Int) extends RepairingActivity @@ -158,11 +225,34 @@ trait InGameHistory { /** * An in-game event must be recorded. * Add new entry to the list (for recent activity). + * Special handling must be conducted for certain events. * @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: VehicleDismountActivity) => + history + .findLast(_.isInstanceOf[VehicleMountActivity]) + .collect { + case event: VehicleMountActivity if event.vehicle.unique == act.vehicle.unique => + history = history :+ InGameActivity.ShareTime(act.copy(pairedEvent = Some(event)), act) + } + .orElse { + history = history :+ act + None + } + case Some(act: VehicleCargoDismountActivity) => + history + .findLast(_.isInstanceOf[VehicleCargoMountActivity]) + .collect { + case event: VehicleCargoMountActivity if event.vehicle.unique == act.vehicle.unique => + history = history :+ InGameActivity.ShareTime(act.copy(pairedEvent = Some(event)), act) + } + .orElse { + history = history :+ act + None + } case Some(act) => history = history :+ act case None => () @@ -188,7 +278,7 @@ trait InGameHistory { LogActivity(DamageFromPainbox(result)) case _: EnvironmentReason => LogActivity(DamageFromEnvironment(result)) - case _ => ; + case _ => LogActivity(DamageFrom(result)) if(result.adversarial.nonEmpty) { lastDamage = Some(result) @@ -209,10 +299,55 @@ trait InGameHistory { } } + /** + * activity that comes from another entity used for scoring;
+ * key - unique reference to that entity; value - history from that entity + */ + private val contributionInheritance: mutable.HashMap[SourceUniqueness, Contribution] = + mutable.HashMap[SourceUniqueness, Contribution]() + + def ContributionFrom(target: PlanetSideGameObject with FactionAffinity with InGameHistory): Option[Contribution] = { + if (target eq this) { + None + } else { + val uniqueTarget = SourceEntry(target).unique + (target.GetContribution(), contributionInheritance.get(uniqueTarget)) match { + case (Some(in), Some(curr)) => + val end = curr.end + val contribution = Contribution(uniqueTarget, curr.entries ++ in.filter(_.time > end)) + contributionInheritance.put(uniqueTarget, contribution) + Some(contribution) + case (Some(in), _) => + val contribution = Contribution(uniqueTarget, in) + contributionInheritance.put(uniqueTarget, contribution) + Some(contribution) + case (None, _) => + None + } + } + } + + def GetContribution(): Option[List[InGameActivity]] = { + Option(GetContributionDuringPeriod(History, duration = Config.app.game.experience.longContributionTime)) + } + + def GetContributionDuringPeriod(list: List[InGameActivity], duration: Long): List[InGameActivity] = { + val earliestEndTime = System.currentTimeMillis() - duration + list.collect { + case event: DamagingActivity if event.health > 0 && event.time > earliestEndTime => event + case event: RepairingActivity if event.amount > 0 && event.time > earliestEndTime => event + } + } + + def HistoryAndContributions(): List[InGameActivity] = { + History ++ contributionInheritance.values.toList + } + def ClearHistory(): List[InGameActivity] = { lastDamage = None val out = history history = List.empty + contributionInheritance.clear() out } } @@ -221,14 +356,14 @@ object InGameHistory { def SpawnReconstructionActivity( obj: PlanetSideGameObject with FactionAffinity with InGameHistory, zoneNumber: Int, - unit: Option[SourceEntry] + unit: Option[PlanetSideGameObject with FactionAffinity with InGameHistory] ): Unit = { - val event: GeneralActivity = if (obj.History.nonEmpty || obj.History.headOption.exists { - _.isInstanceOf[SpawningActivity] - }) { - ReconstructionActivity(ObjectSource(obj), zoneNumber, unit) + val toUnitSource = unit.collect { case o: PlanetSideGameObject with FactionAffinity => SourceEntry(o) } + + val event: GeneralActivity = if (obj.History.isEmpty) { + SpawningActivity(SourceEntry(obj), zoneNumber, toUnitSource) } else { - SpawningActivity(ObjectSource(obj), zoneNumber, unit) + ReconstructionActivity(SourceEntry(obj), zoneNumber, toUnitSource) } if (obj.History.lastOption match { case Some(evt: SpawningActivity) => evt != event @@ -236,6 +371,13 @@ object InGameHistory { case _ => true }) { obj.LogActivity(event) + unit.foreach { o => obj.ContributionFrom(o) } } } + + def ContributionFrom(target: PlanetSideGameObject with FactionAffinity with InGameHistory): Option[Contribution] = { + target + .GetContribution() + .collect { case events => Contribution(SourceEntry(target).unique, events) } + } } diff --git a/src/main/scala/net/psforever/objects/zones/HotSpotInfo.scala b/src/main/scala/net/psforever/objects/zones/HotSpotInfo.scala index 23ac2b116..20b0eacbd 100644 --- a/src/main/scala/net/psforever/objects/zones/HotSpotInfo.scala +++ b/src/main/scala/net/psforever/objects/zones/HotSpotInfo.scala @@ -102,7 +102,7 @@ class ActivityReport { * @return the time */ def Duration_=(time: FiniteDuration): FiniteDuration = { - Duration_=(time.toNanos) + Duration_=(time.toMillis) } /** @@ -112,8 +112,8 @@ class ActivityReport { * @return the time */ def Duration_=(time: Long): FiniteDuration = { - if (time > duration.toNanos) { - duration = FiniteDuration(time, "nanoseconds") + if (time > duration.toMillis) { + duration = FiniteDuration(time, "milliseconds") Renew } Duration @@ -177,7 +177,7 @@ class ActivityReport { * @return the current time */ def Renew: Long = { - val t = System.nanoTime + val t = System.currentTimeMillis() firstReport = firstReport.orElse(Some(t)) lastReport = Some(t) t @@ -191,6 +191,6 @@ class ActivityReport { heat = 0 firstReport = None lastReport = None - duration = FiniteDuration(0, "nanoseconds") + duration = FiniteDuration(0, "milliseconds") } } diff --git a/src/main/scala/net/psforever/objects/zones/ZoneHotSpotProjector.scala b/src/main/scala/net/psforever/objects/zones/ZoneHotSpotProjector.scala index a730afe66..a22b32314 100644 --- a/src/main/scala/net/psforever/objects/zones/ZoneHotSpotProjector.scala +++ b/src/main/scala/net/psforever/objects/zones/ZoneHotSpotProjector.scala @@ -165,7 +165,7 @@ class ZoneHotSpotProjector(zone: Zone, hotspots: ListBuffer[HotSpotInfo], blanki val attackerFaction = attacker.Faction val noPriorHotSpots = hotspots.isEmpty val duration = zone.HotSpotTimeFunction(defender, attacker) - if (duration.toNanos > 0) { + if (duration.toMillis > 0) { val hotspot = TryHotSpot(zone.HotSpotCoordinateFunction(location)) trace( s"updating activity status for ${zone.id} hotspot x=${hotspot.DisplayLocation.x} y=${hotspot.DisplayLocation.y}" @@ -191,11 +191,11 @@ class ZoneHotSpotProjector(zone: Zone, hotspots: ListBuffer[HotSpotInfo], blanki case ZoneHotSpotProjector.BlankingPhase() | Zone.HotSpot.Cleanup() => blanking.cancel() - val curr: Long = System.nanoTime + val curr: Long = System.currentTimeMillis() //blanking dated activity reports val changed = hotspots.flatMap(spot => { spot.Activity.collect { - case (b, a: ActivityReport) if a.LastReport + a.Duration.toNanos <= curr => + case (b, a: ActivityReport) if a.LastReport + a.Duration.toMillis <= curr => a.Clear() //this faction has no more activity in this sector (b, spot) } diff --git a/src/main/scala/net/psforever/objects/zones/exp/EquipmentUseContextWrapper.scala b/src/main/scala/net/psforever/objects/zones/exp/EquipmentUseContextWrapper.scala new file mode 100644 index 000000000..d52c620c7 --- /dev/null +++ b/src/main/scala/net/psforever/objects/zones/exp/EquipmentUseContextWrapper.scala @@ -0,0 +1,38 @@ +// Copyright (c) 2023 PSForever +package net.psforever.objects.zones.exp + +import enumeratum.values.IntEnumEntry + +sealed abstract class EquipmentUseContextWrapper(val value: Int) extends IntEnumEntry { + def equipment: Int + def intermediate: Int +} + +sealed abstract class NoIntermediateUseContextWrapper(override val value: Int) + extends EquipmentUseContextWrapper(value) { + def intermediate: Int = 0 +} + +final case class NoUse() extends NoIntermediateUseContextWrapper(value = -1) { + def equipment: Int = 0 +} + +final case class DamageWith(equipment: Int) extends NoIntermediateUseContextWrapper(value = 0) + +final case class Destroyed(equipment: Int) extends NoIntermediateUseContextWrapper(value = 1) +final case class ReviveAssist(equipment: Int) extends NoIntermediateUseContextWrapper(value = 4) +final case class AmenityDestroyed(equipment: Int, intermediate: Int) extends EquipmentUseContextWrapper(value = 10) +final case class DriverKilled(equipment: Int) extends NoIntermediateUseContextWrapper(value = 12) +final case class GunnerKilled(equipment: Int) extends NoIntermediateUseContextWrapper(value = 13) +final case class PassengerKilled(equipment: Int) extends NoIntermediateUseContextWrapper(value = 14) +final case class CargoDestroyed(equipment: Int, intermediate: Int) extends EquipmentUseContextWrapper(value = 15) +final case class DriverAssist(equipment: Int) extends NoIntermediateUseContextWrapper(value = 18) +final case class HealKillAssist(equipment: Int) extends NoIntermediateUseContextWrapper(value = 20) +final case class ReviveKillAssist(equipment: Int) extends NoIntermediateUseContextWrapper(value = 21) +final case class RepairKillAssist(equipment: Int, intermediate: Int) extends EquipmentUseContextWrapper(value = 22) +final case class AmsRespawnKillAssist(equipment: Int, intermediate: Int) extends EquipmentUseContextWrapper(value = 23) +final case class HotDropKillAssist(equipment: Int, intermediate: Int) extends EquipmentUseContextWrapper(value = 24) +final case class HackKillAssist(equipment: Int, intermediate: Int) extends EquipmentUseContextWrapper(value = 25) +final case class LodestarRearmKillAssist(equipment: Int) extends NoIntermediateUseContextWrapper(value = 26) +final case class AmsResupplyKillAssist(equipment: Int) extends NoIntermediateUseContextWrapper(value = 27) +final case class RouterKillAssist(equipment: Int) extends NoIntermediateUseContextWrapper(value = 28) diff --git a/src/main/scala/net/psforever/objects/zones/exp/ExperienceCalculator.scala b/src/main/scala/net/psforever/objects/zones/exp/ExperienceCalculator.scala index 15f236982..15efb5f95 100644 --- a/src/main/scala/net/psforever/objects/zones/exp/ExperienceCalculator.scala +++ b/src/main/scala/net/psforever/objects/zones/exp/ExperienceCalculator.scala @@ -4,17 +4,11 @@ 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.vital.{InGameActivity, InGameHistory} +import net.psforever.objects.vital.interaction.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] = @@ -32,38 +26,6 @@ object ExperienceCalculator { 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) @@ -74,263 +36,10 @@ class ExperienceCalculator(context: ActorContext[ExperienceCalculator.Command], def onMessage(msg: Command): Behavior[Command] = { msg match { case RewardThisDeath(victim: PlayerSource, lastDamage, history) => - rewardThisPlayerDeath( - victim, - lastDamage, - limitHistoryToThisLife(history.toList) - ) - case _ => - () + KillAssists.rewardThisPlayerDeath(victim, lastDamage, history, zone.AvatarEvents) + + 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/KillAssists.scala b/src/main/scala/net/psforever/objects/zones/exp/KillAssists.scala new file mode 100644 index 000000000..a6da52c96 --- /dev/null +++ b/src/main/scala/net/psforever/objects/zones/exp/KillAssists.scala @@ -0,0 +1,642 @@ +// Copyright (c) 2023 PSForever +package net.psforever.objects.zones.exp + +import akka.actor.ActorRef +import net.psforever.objects.avatar.scoring.{Assist, Death, KDAStat, Kill} +import net.psforever.objects.sourcing.{PlayerSource, SourceEntry} +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 scala.annotation.tailrec +import scala.collection.mutable +import scala.concurrent.duration._ + +/** + * One player will interact using any number of weapons they possess + * that will affect a different player - the target. + * A kill is counted as the last interaction that affects a target so as to drop their health to zero. + * An assist is counted as every other interaction that affects the target up until the kill interaction + * in a similar way to the kill interaction. + * @see `ContributionStats` + * @see `ContributionStatsOutput` + * @see `DamagingActivity` + * @see `HealingActivity` + * @see `InGameActivity` + * @see `InGameHistory` + * @see `PlayerSource` + * @see `RepairingActivity` + * @see `SourceEntry` + */ +object KillAssists { + /** + * Primary landing point for calculating the rewards given for player death. + * Rewards in the form of "battle experience points" are given: + * to the player held responsible for the other player's death - the killer; + * all players whose efforts managed to deal damage to the player who died prior to the killer - assists. + * @param victim player that died + * @param lastDamage purported as the in-game activity that resulted in the player dying + * @param history chronology of activity the game considers noteworthy; + * `lastDamage` should be within this chronology + * @param eventBus where to send the results of the experience determination(s) + * @see `ActorRef` + * @see `AvatarAction.UpdateKillsDeathsAssists` + * @see `AvatarServiceMessage` + * @see `DamageResult` + * @see `rewardThisPlayerDeath` + */ + private[exp] def rewardThisPlayerDeath( + victim: PlayerSource, + lastDamage: Option[DamageResult], + history: Iterable[InGameActivity], + eventBus: ActorRef + ): Unit = { + rewardThisPlayerDeath(victim, lastDamage, history).foreach { case (p, kda) => + eventBus ! AvatarServiceMessage(p.Name, AvatarAction.UpdateKillsDeathsAssists(p.CharId, kda)) + } + } + + /** + * Primary innards of the functionality of calculating the rewards given for player death. + * @param victim player that died + * @param lastDamage purported as the in-game activity that resulted in the player dying + * @param history chronology of activity the game considers noteworthy; + * `lastDamage` should be within this chronology + * @return na + * @see `Assist` + * @see `calculateExperience` + * @see `collectKillAssistsForPlayer` + * @see `DamageResult` + * @see `Death` + * @see `KDAStat` + * @see `limitHistoryToThisLife` + * @see `Support.baseExperience` + */ + private def rewardThisPlayerDeath( + victim: PlayerSource, + lastDamage: Option[DamageResult], + history: Iterable[InGameActivity], + ): Seq[(PlayerSource, KDAStat)] = { + val truncatedHistory = limitHistoryToThisLife(history.toList) + determineKiller(lastDamage, truncatedHistory) match { + case Some((result, killer: PlayerSource)) => + val assists = collectKillAssistsForPlayer(victim, truncatedHistory, Some(killer)) + val fullBep = calculateExperience(killer, victim, truncatedHistory) + 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 }, truncatedHistory.last.time - truncatedHistory.head.time, fullBep)) +: hitSquad + + case _ => + val assists = collectKillAssistsForPlayer(victim, truncatedHistory, None) + val fullBep = Support.baseExperience(victim, truncatedHistory) + val hitSquad = assists.map { + case ContributionStatsOutput(p, w, r) => (p, Assist(victim, w, r, (fullBep * r).toLong)) + }.toSeq + (victim, Death(hitSquad.map { _._1 }, truncatedHistory.last.time - truncatedHistory.head.time, fullBep)) +: hitSquad + } + } + + /** + * Limit the chronology of in-game activity between an starting activity and a concluding activity for a player character. + * The starting activity is signalled by one or two particular events. + * The concluding activity is a condition of one of many common events. + * All of the activities logged in between count. + * @param history chronology of activity the game considers noteworthy + * @return chronology of activity the game considers noteworthy, but truncated + */ + private def limitHistoryToThisLife(history: List[InGameActivity]): List[InGameActivity] = { + val spawnIndex = history.lastIndexWhere { + case _: SpawningActivity => true + case _: RevivingActivity => true + case _ => false + } + val endIndex = history.lastIndexWhere { + case damage: DamagingActivity => damage.data.targetAfter.asInstanceOf[PlayerSource].Health == 0 + case _ => false + } + if (spawnIndex == -1 || endIndex == -1 || spawnIndex > endIndex) { + Nil + } else { + history.slice(spawnIndex, endIndex) + } + } + + /** + * Determine the player who is the origin/owner of the bullet that reduced health to zero. + * @param lastDamageActivity damage result that purports the player who is the killer + * @param history chronology of activity the game considers noteworthy; + * referenced in the case that the suggested `DamageResult` is not suitable to determine a player + * @return player associated + * @see `limitHistoryToThisLife` + */ + private[exp] 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.adversarial.nonEmpty => + (dam, dam.adversarial.get.attacker) + } + .orElse { + limitHistoryToThisLife(history) + .lastOption + .collect { case dam: DamagingActivity => + val res = dam.data + (res, res.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 + * @param victim player to which the 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 `BattleRank.withExperience` + * @see `Support.baseExperience` + */ + private def calculateExperience( + killer: PlayerSource, + victim: PlayerSource, + history: Iterable[InGameActivity] + ): Long = { + //base value (the kill experience before modifiers) + val base = Support.baseExperience(victim, history) + if (base > 1) { + //include battle rank disparity modifier + 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 + } + } + val baseWithDisparity: Long = base + battleRankDisparity + val killCount: Long = victim.progress.kills.size + if (battleRankDisparity > 0) { + //include menace modifier + val pureMenace = calculateMenace(victim) + baseWithDisparity + (killCount * (1f + pureMenace.toFloat / 10f)).toLong + } else { + math.max(baseWithDisparity, killCount) + } + } else { + base + } + } + + /** + * Evaluate chronological in-game activity within a scope of history and + * isolate the interactions that lead to one player dying. + * Factor in interactions that would have the dying player attempt to resist death, if only for a short while longer. + * @param victim player to which the final interaction has reduced health to zero + * @param history chronology of activity the game considers noteworthy + * @param killerOpt optional player that delivers the interaction that reduces the `victim's` health to zero + * @return summary of the interaction in terms of players, equipment activity, and experience + * @see `armorDamageContributors` + * @see `collectKillAssists` + * @see `healthDamageContributors` + * @see `Support.allocateContributors` + * @see `Support.onlyOriginalAssistEntries` + */ + private def collectKillAssistsForPlayer( + victim: PlayerSource, + history: List[InGameActivity], + killerOpt: Option[PlayerSource] + ): Iterable[ContributionStatsOutput] = { + val healthAssists = collectKillAssists( + victim, + history, + Support.allocateContributors(healthDamageContributors) + ) + healthAssists.remove(0L) + healthAssists.remove(victim.CharId) + killerOpt.map { killer => healthAssists.remove(killer.CharId) } + if (Support.wasEverAMax(victim, history)) { + val armorAssists = collectKillAssists( + victim, + history, + Support.allocateContributors(armorDamageContributors) + ) + armorAssists.remove(0L) + armorAssists.remove(victim.CharId) + killerOpt.map { killer => armorAssists.remove(killer.CharId) } + Support.onlyOriginalAssistEntries(healthAssists, armorAssists) + } else { + healthAssists.values + } + } + + /** + * Analyze history based on a discriminating function and format the output. + * @param victim player to which the final interaction has reduced health to zero + * @param history chronology of activity the game considers noteworthy + * @param func mechanism for discerning particular interactions and building a narrative around their history; + * tallies all activity by a certain player using certain equipment + * @return summary of the interaction in terms of players, equipment activity, and experience + */ + private def collectKillAssists( + victim: SourceEntry, + history: List[InGameActivity], + func: (List[InGameActivity], PlanetSideEmpire.Value) => mutable.LongMap[ContributionStats] + ): mutable.LongMap[ContributionStatsOutput] = { + val assists = func(history, victim.Faction).filterNot { case (_, kda) => kda.amount <= 0 } + val total = assists.values.foldLeft(0f)(_ + _.total) + val output = assists.map { case (id, kda) => + (id, ContributionStatsOutput(kda.player, kda.weapons.map { _.equipment }, kda.amount / total)) + } + output.remove(victim.CharId) + output + } + + /** + * In relation to a target player's health, + * build a secondary chronology of how the health value is affected per interaction and + * maintain a quantitative record of that activity in relation to the other players and their equipment. + * @param history chronology of activity the game considers noteworthy + * @param faction empire to target + * @param participants quantitative record of activity in relation to the other players and their equipment + * @return chronology of how the health value is affected per interaction + * @see `contributeWithDamagingActivity` + * @see `contributeWithRecoveryActivity` + * @see `RevivingActivity` + */ + 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.foreach { + case d: DamagingActivity if d.health > 0 => + inOrder = contributeWithDamagingActivity(d, faction, d.health, participants, inOrder) + case r: RevivingActivity => + inOrder = contributeWithRecoveryActivity(r.amount, participants, inOrder) + case h: HealingActivity => + inOrder = contributeWithRecoveryActivity(h.amount, participants, inOrder) + case _ => () + } + inOrder + } + + /** + * In relation to a target player's armor, + * build a secondary chronology of how the armor value is affected per interaction and + * maintain a quantitative record of that activity in relation to the other players and their equipment. + * @param history chronology of activity the game considers noteworthy + * @param faction empire to target + * @param participants quantitative record of activity in relation to the other players and their equipment + * @return chronology of how the armor value is affected per interaction + * @see `contributeWithDamagingActivity` + * @see `contributeWithRecoveryActivity` + */ + private def armorDamageContributors( + 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.foreach { + case d: DamagingActivity if d.amount - d.health > 0 => + inOrder = contributeWithDamagingActivity(d, faction, d.amount - d.health, participants, inOrder) + case r: RepairingActivity => + inOrder = contributeWithRecoveryActivity(r.amount, participants, inOrder) + case _ => () + } + inOrder + } + + /** + * Analyze damaging activity for quantitative records. + * @param activity a particular in-game activity that negative affects a player's health + * @param faction empire to target + * @param amount value + * @param participants quantitative record of activity in relation to the other players and their equipment + * @param order chronology of how the armor value is affected per interaction + * @return chronology of how the armor value is affected per interaction + */ + private def contributeWithDamagingActivity( + activity: DamagingActivity, + faction: PlanetSideEmpire.Value, + amount: Int, + participants: mutable.LongMap[ContributionStats], + order: Seq[(Long, Int)] + ): Seq[(Long, Int)] = { + val data = activity.data + val playerOpt = data.adversarial.collect { case Adversarial(p: PlayerSource, _,_) => p } + contributeWithDamagingActivity( + playerOpt, + data.interaction.cause.attribution, + faction, + amount, + activity.time, + participants, + order + ) + } + + /** + * Analyze damaging activity for quantitative records. + * @param userOpt optional player for the quantitative record + * @param wepid weapon for the quantitative record + * @param faction empire to target + * @param amount value + * @param participants quantitative record of activity in relation to the other players and their equipment + * @param order chronology of how the armor value is affected per interaction + * @return chronology of how the armor value is affected per interaction + */ + private[exp] def contributeWithDamagingActivity( + userOpt: Option[PlayerSource], + wepid: Int, + faction: PlanetSideEmpire.Value, + amount: Int, + time: Long, + participants: mutable.LongMap[ContributionStats], + order: Seq[(Long, Int)] + ): Seq[(Long, Int)] = { + userOpt match { + case Some(user) + if user.Faction != faction => + val whoId = user.CharId + val percentage = amount / user.Definition.MaxHealth.toFloat + val updatedEntry = participants.get(whoId) match { + case Some(mod) => + //previous attacker, just add to entry + val firstWeapon = mod.weapons.head + val newEntry = DamageWith(wepid) + val weapons = if (firstWeapon.equipment == newEntry) { + firstWeapon.copy( + amount = firstWeapon.amount + amount, + shots = firstWeapon.shots + 1, + time = time, + contributions = firstWeapon.contributions + percentage + ) +: mod.weapons.tail + } else { + WeaponStats(newEntry, amount, 1, time, percentage) +: mod.weapons + } + mod.copy( + amount = mod.amount + amount, + weapons = weapons, + total = mod.total + amount, + shots = mod.shots + 1, + time = time + ) + case None => + //new attacker, new entry + ContributionStats( + user, + Seq(WeaponStats(DamageWith(wepid), amount, 1, time, percentage)), + 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 + } + } + } + + /** + * Analyze recovery activity for quantitative records. + * @param amount value + * @param participants quantitative record of activity in relation to the other players and their equipment + * @param order chronology of how the armor value is affected per interaction + * @return chronology of how the armor value is affected per interaction + */ + private[exp] 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)) ++ + 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 + } + } + count += 1 + amt > 0 + } + newOrder ++ order.drop(count) + } +} diff --git a/src/main/scala/net/psforever/objects/zones/exp/KillContributions.scala b/src/main/scala/net/psforever/objects/zones/exp/KillContributions.scala new file mode 100644 index 000000000..93293aaa3 --- /dev/null +++ b/src/main/scala/net/psforever/objects/zones/exp/KillContributions.scala @@ -0,0 +1,930 @@ +// Copyright (c) 2023 PSForever +package net.psforever.objects.zones.exp + +import akka.actor.ActorRef +import net.psforever.objects.GlobalDefinitions +import net.psforever.objects.avatar.scoring.{Kill, SupportActivity} +import net.psforever.objects.sourcing.{BuildingSource, PlayerSource, SourceEntry, SourceUniqueness, TurretSource, VehicleSource} +import net.psforever.objects.vital.{Contribution, HealFromTerminal, InGameActivity, RepairFromTerminal, RevivingActivity, TerminalUsedActivity, VehicleCargoDismountActivity, VehicleCargoMountActivity, VehicleDismountActivity, VehicleMountActivity} +import net.psforever.objects.vital.projectile.ProjectileReason +import net.psforever.objects.zones.exp.rec.{CombinedHealthAndArmorContributionProcess, MachineRecoveryExperienceContributionProcess} +import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage} +import net.psforever.types.{PlanetSideEmpire, Vector3} +import net.psforever.util.Config + +import scala.collection.mutable + +/** + * Kills and assists consider the target, in an exchange of projectiles from the weapons of players towards the target. + * Contributions consider actions of other allied players towards the player who is the source of the projectiles. + * These actions are generally positive for the player. + * @see `Contribution` + * @see `ContributionStats` + * @see `ContributionStatsOutput` + * @see `DamagingActivity` + * @see `GlobalDefinitions` + * @see `HealingActivity` + * @see `InGameActivity` + * @see `InGameHistory` + * @see `Kill` + * @see `PlayerSource` + * @see `RepairingActivity` + * @see `SourceEntry` + * @see `SourceUniqueness` + * @see `VehicleSource` + */ +object KillContributions { + /** the object type ids of various game elements that are recognized for "stat recovery" */ + final val RecoveryItems: Seq[Int] = { + import net.psforever.objects.GlobalDefinitions._ + Seq( + bank, + nano_dispenser, + medicalapplicator, + order_terminal, + order_terminala, + order_terminalb, + medical_terminal, + adv_med_terminal, + bfr_rearm_terminal, + multivehicle_rearm_terminal, + lodestar_repair_terminal + ).collect { _.ObjectId } + } //TODO currently includes things that are not typical equipment but things that express contribution + + /** cached for empty collection returns; please do not add anything to it */ + private val emptyMap: mutable.LongMap[ContributionStats] = mutable.LongMap.empty[ContributionStats] + + /** + * Primary landing point for calculating the rewards given for helping one player kill another player. + * Rewards in the form of "support experience points" are given + * to all allied players that have somehow been involved with the player who killed another player. + * @param target player that delivers the interaction that killed another player; + * history is purportedly composed of events that have happened to this player within a time frame + * @param history chronology of activity the game considers noteworthy + * @param kill the in-game event that maintains information about the other player's death; + * originates from prior statistical management normally + * @param bep battle experience points to be referenced for support experience points conversion + * @param eventBus where to send the results of the experience determination(s) + * @see `ActorRef` + * @see `AvatarAction.UpdateKillsDeathsAssists` + * @see `AvatarServiceMessage` + * @see `rewardTheseSupporters` + * @see `SupportActivity` + */ + private[exp] def rewardTheseSupporters( + target: PlayerSource, + history: Iterable[InGameActivity], + kill: Kill, + bep: Long, + eventBus: ActorRef + ): Unit = { + val victim = kill.victim + //take the output and transform that into contribution distribution data + rewardTheseSupporters(target, history, kill, bep) + .foreach { case (charId, ContributionStatsOutput(player, weapons, exp)) => + eventBus ! AvatarServiceMessage( + player.Name, + AvatarAction.UpdateKillsDeathsAssists(charId, SupportActivity(victim, weapons, exp.toLong)) + ) + } + } + + /** + * Primary innards for calculating the rewards given for helping one player kill another player. + * @param target player that delivers the interaction that killed another player; + * history is purportedly composed of events that have happened to this player within a time frame + * @param history chronology of activity the game considers noteworthy + * @param kill the in-game event that maintains information about the other player's death; + * originates from prior statistical management normally + * @param bep battle experience points to be referenced for support experience points conversion + * returns list of user unique identifiers and + * a summary of the interaction in terms of players, equipment activity, and experience + * @see `ActorRef` + * @see `additionalContributionSources` + * @see `AvatarAction.UpdateKillsDeathsAssists` + * @see `AvatarServiceMessage` + * @see `CombinedHealthAndArmorContributionProcess` + * @see `composeContributionOutput` + * @see `initialScoring` + * @see `KillAssists.calculateMenace` + * @see `limitHistoryToThisLife` + * @see `rewardTheseSupporters` + * @see `SupportActivity` + */ + private[exp] def rewardTheseSupporters( + target: PlayerSource, + history: Iterable[InGameActivity], + kill: Kill, + bep: Long + ): Iterable[(Long, ContributionStatsOutput)] = { + val faction = target.Faction + /* + divide into applicable time periods; + these two periods represent passes over the in-game history to evaluate statistic modification events; + the short time period should stand on its own, but should also be represented in the long time period; + more players should be rewarded if one qualifies for the longer time period's evaluation + */ + val (contributions, (longHistory, shortHistory)) = { + val killTime = kill.time.toDate.getTime + val shortPeriod = killTime - Config.app.game.experience.shortContributionTime + val (contrib, onlyHistory) = history.partition { _.isInstanceOf[Contribution] } + ( + contrib + .collect { case Contribution(unique, entries) => (unique, entries) } + .toMap[SourceUniqueness, List[InGameActivity]], + limitHistoryToThisLife(onlyHistory.toList, killTime).partition { _.time < shortPeriod } + ) + } + //events that are older than 5 minutes are enough to prove one has been alive that long + val empty = mutable.ListBuffer[SourceUniqueness]() + empty.addOne(target.unique) + val otherContributionCalculations = additionalContributionSources(faction, kill, contributions)(_, _, _) + if (longHistory.nonEmpty && KillAssists.calculateMenace(target) > 3) { + //long and short history + val longContributionProcess = new CombinedHealthAndArmorContributionProcess(faction, contributions, Nil) + val shortContributionProcess = new CombinedHealthAndArmorContributionProcess(faction, contributions, Seq(longContributionProcess)) + longContributionProcess.submit(longHistory) + shortContributionProcess.submit(shortHistory) + val longContributionEntries = otherContributionCalculations( + longHistory, + initialScoring(longContributionProcess.output(), bep.toFloat), + empty + ) + val shortContributionEntries = otherContributionCalculations( + shortHistory, + initialScoring(shortContributionProcess.output(), bep.toFloat), + empty + ) + longContributionEntries.remove(target.CharId) + longContributionEntries.remove(kill.victim.CharId) + shortContributionEntries.remove(target.CharId) + shortContributionEntries.remove(kill.victim.CharId) + //combine + (longContributionEntries ++ shortContributionEntries) + .toSeq + .distinctBy(_._2.player.unique) + .flatMap { case (_, stats) => + composeContributionOutput(stats.player.CharId, shortContributionEntries, longContributionEntries, bep) + } + } else { + //short history only + val contributionProcess = new CombinedHealthAndArmorContributionProcess(faction, contributions, Nil) + contributionProcess.submit(shortHistory) + val contributionEntries = otherContributionCalculations( + shortHistory, + initialScoring(contributionProcess.output(), bep.toFloat), + empty + ) + contributionEntries.remove(target.CharId) + contributionEntries.remove(kill.victim.CharId) + contributionEntries + .flatMap { case (_, stats) => + composeContributionOutput(stats.player.CharId, contributionEntries, contributionEntries, bep) + } + } + } + + /** + * Only historical activity that falls within the valid period matters.
+ * Unlike an expected case where the history would be bound by being spawned and being killed, respectively, + * this imposes only the long contribution time limit on events since the latest entry; + * and, it may stop some time after the otherwise closest activity for being spawned. + * @param history the original history + * @param eventTime from which time to start counting backwards + * @return the potentially truncated history + */ + private def limitHistoryToThisLife(history: List[InGameActivity], eventTime: Long): List[InGameActivity] = { + limitHistoryToThisLife(history, eventTime, eventTime - Config.app.game.experience.longContributionTime) + } + + /** + * Only historical activity that falls within the valid period matters. + * @param history the original history + * @param eventTime from which time to start counting backwards + * @param startTime after which time to start counting forwards + * @return the potentially truncated history + */ + private def limitHistoryToThisLife( + history: List[InGameActivity], + eventTime: Long, + startTime: Long + ): List[InGameActivity] = { + history.filter { event => event.time <= eventTime && event.time >= startTime } + } + + /** + * Manipulate contribution scores that have been evaluated up to this point + * for a fixed combination of users and different implements + * by replacing the score using a flat predictable numerical evaluation. + * @param existingParticipants quantitative record of activity in relation to the other players and their equipment + * @param bep battle experience point + * @return quantitative record of activity in relation to the other players and their equipment + */ + private def initialScoring( + existingParticipants: mutable.LongMap[ContributionStats], + bep: Float + ): mutable.LongMap[ContributionStats] = { + //the scoring up to this point should be rate based, but is not perfectly useful for us + existingParticipants.map { case (id, stat) => + val newWeaponStats = stat.weapons.map { weaponStat => + weaponStat.copy(contributions = 10f + weaponStat.shots.toFloat + 0.05f * bep) + } + existingParticipants.put(id, stat.copy(weapons = newWeaponStats)) + } + existingParticipants + } + + /** + * na + * @param faction empire to target + * @param kill the in-game event that maintains information about the other player's death; + * originates from prior statistical management normally + * @param contributions na + * @param history chronology of activity the game considers noteworthy + * @param existingParticipants quantitative record of activity in relation to the other players and their equipment + * @param excludedTargets do not repeat analysis on entities associated with these tokens + * @return quantitative record of activity in relation to the other players and their equipment + * @see `contributeWithRevivalActivity` + * @see `contributeWithTerminalActivity` + * @see `contributeWithVehicleTransportActivity` + * @see `contributeWithVehicleCargoTransportActivity` + * @see `contributeWithKillWhileMountedActivity` + */ + private def additionalContributionSources( + faction: PlanetSideEmpire.Value, + kill: Kill, + contributions: Map[SourceUniqueness, List[InGameActivity]] + ) + ( + history: List[InGameActivity], + existingParticipants: mutable.LongMap[ContributionStats], + excludedTargets: mutable.ListBuffer[SourceUniqueness] + ): mutable.LongMap[ContributionStats] = { + contributeWithRevivalActivity(history, existingParticipants) + contributeWithTerminalActivity(history, faction, contributions, excludedTargets, existingParticipants) + contributeWithVehicleTransportActivity(kill, history, faction, contributions, excludedTargets, existingParticipants) + contributeWithVehicleCargoTransportActivity(kill, history, faction, contributions, excludedTargets, existingParticipants) + contributeWithKillWhileMountedActivity(kill, faction, contributions, excludedTargets, existingParticipants) + existingParticipants.remove(0) + existingParticipants + } + + /** + * Gather and reward specific in-game equipment use activity.
+ * If the player who performed the killing interaction is mounted in something, + * determine if the mount is has been effected by previous in-game interactions + * that resulted in positive stat maintenance or development. + * Also, reward the owner, if an owner exists, for providing the mount. + * @param kill the in-game event that maintains information about the other player's death + * @param faction empire to target + * @param contributions mapping between external entities + * the target has interacted with in the form of in-game activity + * and history related to the time period in which the interaction ocurred + * @param excludedTargets if a potential target is listed here already, skip processing it + * @param out quantitative record of activity in relation to the other players and their equipment + * @see `combineStatsInto` + * @see `extractContributionsForMachineByTarget` + */ + private def contributeWithKillWhileMountedActivity( + kill: Kill, + faction: PlanetSideEmpire.Value, + contributions: Map[SourceUniqueness, List[InGameActivity]], + excludedTargets: mutable.ListBuffer[SourceUniqueness], + out: mutable.LongMap[ContributionStats] + ): Unit = { + val eventTime = kill.time.toDate.getTime + (kill + .info + .interaction + .cause match { + case p: ProjectileReason => p.projectile.mounted_in.map { case (_, src) => Some((src, p.projectile.owner)) } + case _ => None + }) + .collect { + case Some((mount: VehicleSource, attacker: PlayerSource)) if !excludedTargets.contains(mount.unique) => + mount.owner + .collect { + case owner if owner == attacker.unique => + //owner is gunner; reward only repairs + excludedTargets.addOne(owner) + owner + case owner => + //gunner is different from owner; reward driver and repairs + excludedTargets.addOne(owner) + excludedTargets.addOne(attacker.unique) + val time = kill.time.toDate.getTime + val weaponStat = Support.calculateSupportExperience( + event = "mounted-kill", + WeaponStats(DriverAssist(mount.Definition.ObjectId), 1, 1, time, 1f) + ) + combineStatsInto( + out, + ( + owner.charId, + ContributionStats( + PlayerSource(owner, mount.Position), + Seq(weaponStat), + 1, + 1, + 1, + time + ) + ) + ) + owner + } + combineStatsInto( + out, + extractContributionsForMachineByTarget(mount, faction, eventTime, contributions, excludedTargets, eventOutputType="support-repair") + ) + case Some((mount: TurretSource, _: PlayerSource)) if !excludedTargets.contains(mount.unique) => + combineStatsInto( + out, + extractContributionsForMachineByTarget(mount, faction, eventTime, contributions, excludedTargets, eventOutputType="support-repair-turret") + ) + } + } + + /** + * Gather and reward specific in-game equipment use activity.
+ * na + * @param kill the in-game event that maintains information about the other player's death + * @param history chronology of activity the game considers noteworthy + * @param faction empire to target + * @param contributions mapping between external entities + * the target has interacted with in the form of in-game activity + * and history related to the time period in which the interaction ocurred + * @param excludedTargets if a potential target is listed here already, skip processing it + * @param out quantitative record of activity in relation to the other players and their equipment + * @see `combineStatsInto` + * @see `extractContributionsForMachineByTarget` + */ + private def contributeWithVehicleTransportActivity( + kill: Kill, + history: List[InGameActivity], + faction: PlanetSideEmpire.Value, + contributions: Map[SourceUniqueness, List[InGameActivity]], + excludedTargets: mutable.ListBuffer[SourceUniqueness], + out: mutable.LongMap[ContributionStats] + ): Unit = { + /* + collect the dismount activity of all vehicles from which this player is not the owner + make certain all dismount activity can be paired with a mounting activity + certain other qualifications of the prior mounting must be met before the support bonus applies + */ + val dismountActivity = history + .collect { + case out: VehicleDismountActivity + if !out.vehicle.owner.contains(out.player.unique) && out.pairedEvent.nonEmpty => (out.pairedEvent.get, out) + } + .collect { + case (in: VehicleMountActivity, out: VehicleDismountActivity) + if in.vehicle.unique == out.vehicle.unique && + out.vehicle.Faction == out.player.Faction && + (in.vehicle.Definition == GlobalDefinitions.router || { + val inTime = in.time + val outTime = out.time + out.player.progress.kills.exists { death => + val deathTime = death.info.interaction.hitTime + inTime < deathTime && deathTime <= outTime + } + } || { + val sameZone = in.zoneNumber == out.zoneNumber + val distanceTransported = Vector3.DistanceSquared(in.vehicle.Position.xy, out.vehicle.Position.xy) + val distanceMoved = { + val killLocation = kill.info.adversarial + .collect { adversarial => adversarial.attacker.Position.xy } + .getOrElse(Vector3.Zero) + Vector3.DistanceSquared(killLocation, out.player.Position.xy) + } + val timeSpent = out.time - in.time + distanceMoved < 5625f /* 75m */ && + (timeSpent >= 210000L /* 3:30 */ || + (sameZone && (distanceTransported > 160000f /* 400m */ || + distanceTransported > 10000f /* 100m */ && timeSpent >= 60000L /* 1:00m */)) || + (!sameZone && (distanceTransported > 10000f /* 100m */ || timeSpent >= 120000L /* 2:00 */ ))) + }) => + out + } + //apply + dismountActivity + .groupBy { _.vehicle } + .collect { case (mount, dismountsFromVehicle) if mount.owner.nonEmpty => + val promotedOwner = PlayerSource(mount.owner.get, mount.Position) + val (equipmentUseContext, equipmentUseEvent) = mount.Definition match { + case v @ GlobalDefinitions.router => + (RouterKillAssist(v.ObjectId), "router") + case v => + (HotDropKillAssist(v.ObjectId, 0), "hotdrop") + } + val size = dismountsFromVehicle.size + val time = dismountsFromVehicle.maxBy(_.time).time + val weaponStat = Support.calculateSupportExperience( + equipmentUseEvent, + WeaponStats(equipmentUseContext, size, size, time, 1f) + ) + combineStatsInto( + out, + ( + promotedOwner.CharId, + ContributionStats(promotedOwner, Seq(weaponStat), size, size, size, time) + ) + ) + contributions.get(mount.unique).collect { + case list => + val mountHistory = dismountsFromVehicle + .flatMap { event => + val eventTime = event.time + val startTime = event.pairedEvent.get.time - Config.app.game.experience.longContributionTime + limitHistoryToThisLife(list, eventTime, startTime) + } + .distinctBy(_.time) + combineStatsInto( + out, + extractContributionsForMachineByTarget(mount, faction, mountHistory, contributions, excludedTargets, eventOutputType="support-repair") + ) + } + } + } + + /** + * Gather and reward specific in-game equipment use activity.
+ * na + * @param kill the in-game event that maintains information about the other player's death + * @param faction empire to target + * @param contributions mapping between external entities + * the target has interacted with in the form of in-game activity + * and history related to the time period in which the interaction ocurred + * @param excludedTargets if a potential target is listed here already, skip processing it + * @param out quantitative record of activity in relation to the other players and their equipment + * @see `combineStatsInto` + * @see `extractContributionsForMachineByTarget` + */ + private def contributeWithVehicleCargoTransportActivity( + kill: Kill, + history: List[InGameActivity], + faction: PlanetSideEmpire.Value, + contributions: Map[SourceUniqueness, List[InGameActivity]], + excludedTargets: mutable.ListBuffer[SourceUniqueness], + out: mutable.LongMap[ContributionStats] + ): Unit = { + /* + collect the dismount activity of all vehicles from which this player is not the owner + make certain all dismount activity can be paired with a mounting activity + certain other qualifications of the prior mounting must be met before the support bonus applies + */ + val dismountActivity = history + .collect { + case out: VehicleCargoDismountActivity + if out.vehicle.owner.nonEmpty && out.pairedEvent.nonEmpty => (out.pairedEvent.get, out) + } + .collect { + case (in: VehicleCargoMountActivity, out: VehicleCargoDismountActivity) + if in.vehicle.unique == out.vehicle.unique && + out.vehicle.Faction == out.cargo.Faction && + (in.vehicle.Definition == GlobalDefinitions.router || { + val distanceTransported = Vector3.DistanceSquared(in.vehicle.Position.xy, out.vehicle.Position.xy) + val distanceMoved = { + val killLocation = kill.info.adversarial + .collect { adversarial => adversarial.attacker.Position.xy } + .getOrElse(Vector3.Zero) + Vector3.DistanceSquared(killLocation, out.cargo.Position.xy) + } + val timeSpent = out.time - in.time + distanceMoved < 5625f /* 75m */ && + (timeSpent >= 210000 /* 3:30 */ || distanceTransported > 360000f /* 600m */) + }) => + out + } + //apply + dismountActivity + .groupBy { _.cargo } + .collect { case (mount, dismountsFromVehicle) if mount.owner.nonEmpty => + val promotedOwner = PlayerSource(mount.owner.get, mount.Position) + val mountId = mount.Definition.ObjectId + dismountsFromVehicle + .groupBy(_.vehicle) + .map { case (vehicle, events) => + val size = events.size + val time = events.maxBy(_.time).time + val weaponStat = Support.calculateSupportExperience( + event = "hotdrop", + WeaponStats(HotDropKillAssist(vehicle.Definition.ObjectId, mountId), size, size, time, 1f) + ) + (vehicle, vehicle.owner, Seq(weaponStat)) + } + .collect { case (vehicle, Some(owner), statContext) => + combineStatsInto( + out, + ( + owner.charId, + ContributionStats(promotedOwner, statContext, 1, 1, 1, statContext.head.time) + ) + ) + contributions.get(mount.unique).collect { + case list => + val mountHistory = dismountsFromVehicle + .flatMap { event => + val eventTime = event.time + val startTime = event.pairedEvent.get.time - Config.app.game.experience.longContributionTime + limitHistoryToThisLife(list, eventTime, startTime) + } + .distinctBy(_.time) + combineStatsInto( + out, + extractContributionsForMachineByTarget(mount, faction, mountHistory, contributions, excludedTargets, eventOutputType="support-repair") + ) + } + contributions.get(vehicle.unique).collect { + case list => + val carrierHistory = dismountsFromVehicle + .flatMap { event => + val eventTime = event.time + val startTime = event.pairedEvent.get.time - Config.app.game.experience.longContributionTime + limitHistoryToThisLife(list, eventTime, startTime) + } + .distinctBy(_.time) + combineStatsInto( + out, + extractContributionsForMachineByTarget(vehicle, faction, carrierHistory, contributions, excludedTargets, eventOutputType="support-repair") + ) + } + } + } + } + + /** + * Gather and reward specific in-game equipment use activity.
+ * na + * @param faction empire to target + * @param contributions mapping between external entities + * the target has interacted with in the form of in-game activity + * and history related to the time period in which the interaction ocurred + * @param excludedTargets if a potential target is listed here already, skip processing it + * @param out quantitative record of activity in relation to the other players and their equipment + * @see `AmsResupplyKillAssist` + * @see `BuildingSource` + * @see `combineStatsInto` + * @see `contributeWithTerminalActivity` + * @see `extractContributionsForMachineByTarget` + * @see `HackKillAssist` + * @see `HealFromTerminal` + * @see `LodestarRearmKillAssist` + * @see `RepairFromTerminal` + * @see `RepairKillAssist` + * @see `TerminalUsedActivity` + */ + private def contributeWithTerminalActivity( + history: List[InGameActivity], + faction: PlanetSideEmpire.Value, + contributions: Map[SourceUniqueness, List[InGameActivity]], + excludedTargets: mutable.ListBuffer[SourceUniqueness], + out: mutable.LongMap[ContributionStats] + ): Unit = { + history + .collect { + case h: HealFromTerminal => (h.term, h) + case r: RepairFromTerminal => (r.term, r) + case t: TerminalUsedActivity => (t.terminal, t) + } + .groupBy(_._1.unique) + .map { + case (_, events1) => + val (termThings1, _) = events1.unzip + val hackContext = HackKillAssist(GlobalDefinitions.remote_electronics_kit.ObjectId, termThings1.head.Definition.ObjectId) + if (termThings1.exists(t => t.Faction != faction && t.hacked.nonEmpty)) { + /* + if the terminal has been hacked, + and the original terminal does not align with our own faction, + then the support must be reported as a hack; + if we are the same faction as the terminal, then the hacked condition is irrelevant + */ + events1 + .collect { case out @ (t, _) if t.hacked.nonEmpty => out } + .groupBy { case (t, _) => t.hacked.get.player.unique } + .foreach { case (_, events2) => + val (termThings2, events3) = events2.unzip + val hacker = termThings2.head.hacked.get.player + val size = events3.size + val time = events3.maxBy(_.time).time + val weaponStats = Support.calculateSupportExperience( + event = "hack", + WeaponStats(hackContext, size, size, time, 1f) + ) + combineStatsInto( + out, + ( + hacker.CharId, + ContributionStats( + hacker, + Seq(weaponStats), + size, + size, + size, + time + ) + ) + ) + } + } else if (termThings1.exists(_.Faction == faction)) { + //faction-aligned terminal + val (_, events2) = events1.unzip + val eventTime = events2.maxBy(_.time).time + val startTime = events2.minBy(_.time).time - Config.app.game.experience.longContributionTime + val termThingsHead = termThings1.head + val (equipmentUseContext, equipmentUseEvent, installationEvent, target) = termThingsHead.installation match { + case v: VehicleSource => + termThingsHead.Definition match { + case GlobalDefinitions.order_terminala => + (AmsResupplyKillAssist(GlobalDefinitions.order_terminala.ObjectId), "ams-resupply", "support-repair", Some(v)) + case GlobalDefinitions.order_terminalb => + (AmsResupplyKillAssist(GlobalDefinitions.order_terminalb.ObjectId), "ams-resupply", "support-repair", Some(v)) + case GlobalDefinitions.lodestar_repair_terminal => + (RepairKillAssist(GlobalDefinitions.lodestar_repair_terminal.ObjectId, v.Definition.ObjectId), "lodestar-repair", "support-repair", Some(v)) + case GlobalDefinitions.bfr_rearm_terminal => + (LodestarRearmKillAssist(GlobalDefinitions.bfr_rearm_terminal.ObjectId), "lodestar-rearm", "support-repair", Some(v)) + case GlobalDefinitions.multivehicle_rearm_terminal => + (LodestarRearmKillAssist(GlobalDefinitions.multivehicle_rearm_terminal.ObjectId), "lodestar-rearm", "support-repair", Some(v)) + case _ => + (NoUse(), "", "", None) + } + case _: BuildingSource => + (NoUse(), "", "support-repair-terminal", Some(termThingsHead)) + case _ => + (NoUse(), "", "", None) + } + target.map { src => + combineStatsInto( + out, + extractContributionsForMachineByTarget(src, faction, eventTime, startTime, contributions, excludedTargets, installationEvent) + ) + } + events1 + .map { case (a, b) => (a.installation, b) } + .collect { case (installation: VehicleSource, evt) if installation.owner.nonEmpty => (installation, evt) } + .groupBy(_._1.owner.get) + .collect { case (owner, list) => + val (installations, events2) = list.unzip + val size = events2.size + val time = events2.maxBy(_.time).time + val weaponStats = Support.calculateSupportExperience( + equipmentUseEvent, + WeaponStats(equipmentUseContext, size, size, time, 1f) + ) + combineStatsInto( + out, + ( + owner.charId, + ContributionStats( + PlayerSource(owner, installations.head.Position), + Seq(weaponStats), + size, + size, + size, + time + ) + ) + ) + } + } + None + } + } + + /** + * Gather and reward specific in-game equipment use activity.
+ * na + * @param history chronology of activity the game considers noteworthy + * @param out quantitative record of activity in relation to the other players and their equipment + * @see `combineStatsInto` + * @see `ReviveKillAssist` + */ + private def contributeWithRevivalActivity( + history: List[InGameActivity], + out: mutable.LongMap[ContributionStats] + ): Unit = { + history + .collect { case rev: RevivingActivity => rev } + .groupBy(_.user.CharId) + .map { case (id, revivesByThisPlayer) => + val user = revivesByThisPlayer.head.user + revivesByThisPlayer + .groupBy(_.equipment) + .map { case (definition, events) => + val eventSize = events.size + val objectId = definition.ObjectId + val time = events.maxBy(_.time).time + combineStatsInto( + out, + ( + id, + ContributionStats( + user, + Seq({ + Support.calculateSupportExperience( + event = "revival", + WeaponStats(ReviveKillAssist(objectId), 1, eventSize, time, 1f) + ) + }), + eventSize, + eventSize, + eventSize, + time + ) + ) + ) + } + } + } + + /** + * na + * Mainly produces repair events. + * @param target entity external to the subject of the kill + * @param faction empire to target + * @param time na + * @param contributions mapping between external entities + * the target has interacted with in the form of in-game activity + * and history related to the time period in which the interaction ocurred + * @param excludedTargets if a potential target is listed here already, skip processing it + * @return quantitative record of activity in relation to the other players and their equipment + */ + private def extractContributionsForMachineByTarget( + target: SourceEntry, + faction: PlanetSideEmpire.Value, + time: Long, + contributions: Map[SourceUniqueness, List[InGameActivity]], + excludedTargets: mutable.ListBuffer[SourceUniqueness], + eventOutputType: String + ): mutable.LongMap[ContributionStats] = { + val start: Long = time - Config.app.game.experience.longContributionTime + extractContributionsForMachineByTarget(target, faction, time, start, contributions, excludedTargets, eventOutputType) + } + + /** + * na + * Mainly produces repair events. + * @param target entity external to the subject of the kill + * @param faction empire to target + * @param eventTime na + * @param startTime na + * @param contributions mapping between external entities + * the target has interacted with in the form of in-game activity + * and history related to the time period in which the interaction ocurred + * @param excludedTargets if a potential target is listed here already, skip processing it + * @return quantitative record of activity in relation to the other players and their equipment + * @see `limitHistoryToThisLife` + */ + private def extractContributionsForMachineByTarget( + target: SourceEntry, + faction: PlanetSideEmpire.Value, + eventTime: Long, + startTime: Long, + contributions: Map[SourceUniqueness, List[InGameActivity]], + excludedTargets: mutable.ListBuffer[SourceUniqueness], + eventOutputType: String + ): mutable.LongMap[ContributionStats] = { + val unique = target.unique + val history = limitHistoryToThisLife(contributions.getOrElse(unique, List()), eventTime, startTime) + extractContributionsForMachineByTarget(target, faction, history, contributions, excludedTargets, eventOutputType) + } + + /** + * na + * Mainly produces repair events. + * @param target entity external to the subject of the kill + * @param faction empire to target + * @param history na + * @param contributions mapping between external entities + * the target has interacted with in the form of in-game activity + * and history related to the time period in which the interaction ocurred + * @param excludedTargets if a potential target is listed here already, skip processing it + * @return quantitative record of activity in relation to the other players and their equipment + * @see `cullContributorImplements` + * @see `emptyMap` + * @see `MachineRecoveryExperienceContributionProcess` + */ + private def extractContributionsForMachineByTarget( + target: SourceEntry, + faction: PlanetSideEmpire.Value, + history: List[InGameActivity], + contributions: Map[SourceUniqueness, List[InGameActivity]], + excludedTargets: mutable.ListBuffer[SourceUniqueness], + eventOutputType: String + ): mutable.LongMap[ContributionStats] = { + val unique = target.unique + if (!excludedTargets.contains(unique) && history.nonEmpty) { + excludedTargets.addOne(unique) + val process = new MachineRecoveryExperienceContributionProcess(faction, contributions, eventOutputType, excludedTargets) + process.submit(history) + cullContributorImplements(process.output()) + } else { + emptyMap + } + } + + /** + * na + * @param main quantitative record of activity in relation to the other players and their equipment + * @param transferFrom quantitative record of activity in relation to the other players and their equipment + * @return quantitative record of activity in relation to the other players and their equipment + * @see `combineStatsInto` + */ + private def combineStatsInto( + main: mutable.LongMap[ContributionStats], + transferFrom: mutable.LongMap[ContributionStats] + ): mutable.LongMap[ContributionStats] = { + transferFrom.foreach { (entry: (Long, ContributionStats)) => combineStatsInto(main, entry) } + main + } + + /** + * na + * @param main quantitative record of activity in relation to the other players and their equipment + * @param entry two value tuple representing: + * a player's unique identifier, + * and a quantitative record of activity in relation to the other players and their equipment + * @see `Support.combineWeaponStats` + */ + private def combineStatsInto(main: mutable.LongMap[ContributionStats], entry: (Long, ContributionStats)): Unit = { + val (id, sampleStats) = entry + main.get(id) match { + case Some(foundStats) => + main.put(id, foundStats.copy(weapons = Support.combineWeaponStats(foundStats.weapons, sampleStats.weapons))) + case None => + main.put(id, sampleStats) + } + } + + /** + * Filter quantitative records based on the presence of specific equipment used for statistic recovery. + * @param input quantitative record of activity in relation to the other players and their equipment + * @return quantitative record of activity in relation to the other players and their equipment + * @see `RecoveryItems` + */ + private[exp] def cullContributorImplements( + input: mutable.LongMap[ContributionStats] + ): mutable.LongMap[ContributionStats] = { + input.collect { case (id, entry) => + (id, entry.copy(weapons = entry.weapons.filter { stats => RecoveryItems.contains(stats.equipment.equipment) })) + }.filter { case (_, entry) => + entry.weapons.nonEmpty + } + } + + /** + * na + * @param charId the unique identifier being targeted + * @param shortPeriod quantitative record of activity in relation to the other players and their equipment + * @param longPeriod quantitative record of activity in relation to the other players and their equipment + * @param max maximum value for the third output value + * @return two value tuple representing: + * a player's unique identifier, + * and a summary of the interaction in terms of players, equipment activity, and experience + * @see `composeContributionOutput` + */ + private def composeContributionOutput( + charId: Long, + shortPeriod: mutable.LongMap[ContributionStats], + longPeriod: mutable.LongMap[ContributionStats], + max: Long + ): Option[(Long, ContributionStatsOutput)] = { + composeContributionOutput(charId, longPeriod, modifier=0.8f, max) + .orElse { composeContributionOutput(charId, shortPeriod, modifier=1f, max) } + .collect { + case (player, weaponIds, experience) => + (charId, ContributionStatsOutput(player, weaponIds, experience)) + } + } + + /** + * na + * @param charId the unique identifier being targeted + * @param stats quantitative record of activity in relation to the other players and their equipment + * @param modifier modifier value for the potential third output value + * @param max maximum value for the third output value + * @return three value tuple representing: + * player, + * the context in which certain equipment is being used, + * and a final value for the awarded support experience points + */ + private def composeContributionOutput( + charId: Long, + stats: mutable.LongMap[ContributionStats], + modifier: Float, + max: Long + ): Option[(PlayerSource, Seq[EquipmentUseContextWrapper], Float)] = { + stats + .get(charId) + .collect { + case entry => + val (weapons, contributions) = entry.weapons.map { entry => (entry.equipment, entry.contributions) }.unzip + ( + entry.player, + weapons.distinct, + modifier * math.floor(math.min(contributions.foldLeft(0f)(_ + _), max.toFloat)).toFloat + ) + } + } +} diff --git a/src/main/scala/net/psforever/objects/zones/exp/KillDeathAssists.scala b/src/main/scala/net/psforever/objects/zones/exp/KillDeathAssists.scala deleted file mode 100644 index 4560b7a80..000000000 --- a/src/main/scala/net/psforever/objects/zones/exp/KillDeathAssists.scala +++ /dev/null @@ -1,40 +0,0 @@ -// 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 index a27b6e4a1..ef5b1b70f 100644 --- a/src/main/scala/net/psforever/objects/zones/exp/Stats.scala +++ b/src/main/scala/net/psforever/objects/zones/exp/Stats.scala @@ -3,24 +3,39 @@ package net.psforever.objects.zones.exp import net.psforever.objects.sourcing.PlayerSource +sealed trait ItemUseStats { + def equipment: EquipmentUseContextWrapper + def shots: Int + def time: Long + def contributions: Float +} + private case class WeaponStats( - weapon_id: Int, + equipment: EquipmentUseContextWrapper, amount: Int, shots: Int, - time: Long - ) + time: Long, + contributions: Float + ) extends ItemUseStats -private case class ContributionStats( - player: PlayerSource, - weapons: Seq[WeaponStats], - amount: Int, - totalDamage: Int, - shots: Int, - time: Long - ) +private case class EquipmentStats( + equipment: EquipmentUseContextWrapper, + shots: Int, + time: Long, + contributions: Float + ) extends ItemUseStats + +private[exp] case class ContributionStats( + player: PlayerSource, + weapons: Seq[WeaponStats], + amount: Int, + total: Int, + shots: Int, + time: Long + ) sealed case class ContributionStatsOutput( player: PlayerSource, - implements: Seq[Int], + implements: Seq[EquipmentUseContextWrapper], percentage: Float ) diff --git a/src/main/scala/net/psforever/objects/zones/exp/Support.scala b/src/main/scala/net/psforever/objects/zones/exp/Support.scala new file mode 100644 index 000000000..0ac83a35c --- /dev/null +++ b/src/main/scala/net/psforever/objects/zones/exp/Support.scala @@ -0,0 +1,239 @@ +// Copyright (c) 2023 PSForever +package net.psforever.objects.zones.exp + +import net.psforever.objects.sourcing.PlayerSource +import net.psforever.objects.vital.{InGameActivity, ReconstructionActivity, RepairFromExoSuitChange, SpawningActivity} +import net.psforever.types.{ExoSuitType, PlanetSideEmpire} +import net.psforever.util.Config + +import scala.collection.mutable + +/** + * Functions to assist experience calculation and history manipulation and analysis. + */ +object Support { + private val sep = Config.app.game.experience.sep + + /** + * Calculate a base experience value to consider additional reasons for points. + * @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 { + 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 + } else { + 1L + } + if (base > 1) { + //black ops modifier + base// * Config.app.game.experience.bep.base.bopsMultiplier + } else { + base + } + } + + /** + * Combine two quantitative records into one, maintaining only the original entries. + * @param first one quantitative record + * @param second another quantitative record + * @param combiner mechanism for determining how to combine quantitative records; + * defaults to an additive combiner with a small multiplier value + * @return the combined quantitative records + * @see `defaultAdditiveOutputCombiner` + * @see `onlyOriginalAssistEntriesIterable` + */ + private[exp] def onlyOriginalAssistEntries( + first: mutable.LongMap[ContributionStatsOutput], + second: mutable.LongMap[ContributionStatsOutput], + combiner: (ContributionStatsOutput, ContributionStatsOutput)=>ContributionStatsOutput = + defaultAdditiveOutputCombiner(multiplier = 0.05f) + ): Iterable[ContributionStatsOutput] = { + onlyOriginalAssistEntriesIterable(first.values, second.values, combiner) + } + + /** + * Combine two quantitative records into one, maintaining only the original entries. + * @param first one quantitative record + * @param second another quantitative record + * @param combiner mechanism for determining how to combine quantitative records; + * defaults to an additive combiner with a small multiplier value + * @return the combined quantitative records + * @see `defaultAdditiveOutputCombiner` + */ + private[exp] def onlyOriginalAssistEntriesIterable( + first: Iterable[ContributionStatsOutput], + second: Iterable[ContributionStatsOutput], + combiner: (ContributionStatsOutput, ContributionStatsOutput)=>ContributionStatsOutput = + defaultAdditiveOutputCombiner(multiplier = 0.05f) + ): Iterable[ContributionStatsOutput] = { + if (second.isEmpty) { + first + } else if (first.isEmpty) { + second + } else { + //overlap discriminated by percentage + val shared: mutable.LongMap[ContributionStatsOutput] = mutable.LongMap[ContributionStatsOutput]() + for { + h @ ContributionStatsOutput(hid, _, _) <- first + a @ ContributionStatsOutput(aid, _, _) <- second + out = combiner(h, a) + id = out.player.CharId + if hid == aid && shared.put(id, out).isEmpty + } yield () + val sharedKeys = shared.keys + (first ++ second).filterNot { case ContributionStatsOutput(id, _, _) => sharedKeys.exists(_ == id.CharId) } ++ shared.values + } + } + + /** + * Combine two quantitative records into one, maintaining only the original entries. + * @param multiplier adjust the combined + * @param first one quantitative record + * @param second another quantitative record + * @return the combined quantitative records + */ + private def defaultAdditiveOutputCombiner( + multiplier: Float + ) + ( + first: ContributionStatsOutput, + second: ContributionStatsOutput + ): ContributionStatsOutput = { + if (first.percentage < second.percentage) + second.copy(implements = (second.implements ++ first.implements).distinct, percentage = first.percentage + second.implements.size * multiplier) + else + first.copy(implements = (first.implements ++ second.implements).distinct, percentage = second.percentage + second.implements.size * multiplier) + } + + /** + * Take two sequences of equipment statistics + * and combine both lists where overlap of the same equipment use is added together per field. + * If one sequence comtains more elements of the same type of equipment use, + * the additional entries may become lost. + * @param first statistics in relation to equipment + * @param second statistics in relation to equipment + * @return statistics in relation to equipment + */ + private[exp] def combineWeaponStats( + first: Seq[WeaponStats], + second: Seq[WeaponStats] + ): Seq[WeaponStats] = { + val (firstInSecond, firstAlone) = first.partition(firstStat => second.exists(_.equipment == firstStat.equipment)) + val (secondInFirst, secondAlone) = second.partition(secondStat => firstInSecond.exists(_.equipment == secondStat.equipment)) + val combined = firstInSecond.flatMap { firstStat => + secondInFirst + .filter(_.equipment == firstStat.equipment) + .map { secondStat => + firstStat.copy( + shots = firstStat.shots + secondStat.shots, + amount = firstStat.amount + secondStat.amount, + contributions = firstStat.contributions + secondStat.contributions, + time = math.max(firstStat.time, secondStat.time) + ) + } + } + firstAlone ++ secondAlone ++ combined + } + + /** + * Run a function against history, targeting a certain faction. + * @param tallyFunc the history analysis function + * @param history chronology of activity the game considers noteworthy + * @param faction empire to target + * @return quantitative record of activity in relation to the other players and their equipment + */ + private[exp] 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 + } + + /** + * You better not fail this purity test. + * @param player player being tested + * @param history chronology of activity the game considers noteworthy; + * allegedly associated with this player + * @return `true`, if the player has ever committed a great shame; + * `false`, otherwise ... and it better be + */ + 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 + } + } + + /** + * Take a weapon statistics entry and calculate the support experience value resulting from this support event. + * The complete formula is:

+ * `base + shots-multplier * ln(shots^exp + 2) + amount-multiplier * amount`

+ * ... where the middle field can be truncated into:

+ * `shots-multplier * shots`

+ * ... without the natural logarithm exponent defined. + * Limits can be applied to the number of shots and/or to the amount, + * which will either zero the calculations or cap the results. + * @param event identification for the event calculation parameters + * @param weaponStat base weapon stat entry to be modified + * @param canNotFindEventDefaultValue custom default value + * @return weapon stat entry with a modified for the experience + */ + private[exp] def calculateSupportExperience( + event: String, + weaponStat: WeaponStats, + canNotFindEventDefaultValue: Option[Float] = None + ): WeaponStats = { + val rewards: Float = sep.events + .find(evt => event.equals(evt.name)) + .map { event => + val shots = weaponStat.shots + val shotsMultiplier = event.shotsMultiplier + if (shotsMultiplier > 0f && shots < event.shotsCutoff) { + val modifiedShotsReward: Float = { + val partialShots = math.min(event.shotsLimit, shots).toFloat + shotsMultiplier * (if (event.shotsNatLog > 0f) { + math.log(math.pow(partialShots, event.shotsNatLog) + 2d).toFloat + } else { + partialShots + }) + } + val modifiedAmountReward: Float = event.amountMultiplier * weaponStat.amount.toFloat + event.base + modifiedShotsReward + modifiedAmountReward + } else { + 0f + } + } + .getOrElse( + canNotFindEventDefaultValue.getOrElse(sep.canNotFindEventDefaultValue.toFloat) + ) + weaponStat.copy(contributions = rewards) + } +} diff --git a/src/main/scala/net/psforever/objects/zones/exp/SupportExperienceCalculator.scala b/src/main/scala/net/psforever/objects/zones/exp/SupportExperienceCalculator.scala new file mode 100644 index 000000000..6dbda6ef2 --- /dev/null +++ b/src/main/scala/net/psforever/objects/zones/exp/SupportExperienceCalculator.scala @@ -0,0 +1,44 @@ +// 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.Kill +import net.psforever.objects.serverobject.affinity.FactionAffinity +import net.psforever.objects.sourcing.{PlayerSource, SourceEntry} +import net.psforever.objects.vital.{InGameActivity, InGameHistory} +import net.psforever.objects.zones.Zone + +object SupportExperienceCalculator { + def apply(zone: Zone): Behavior[Command] = + Behaviors.supervise[Command] { + Behaviors.setup(context => new SupportExperienceCalculator(context, zone)) + }.onFailure[Exception](SupervisorStrategy.restart) + + sealed trait Command + + final case class RewardOurSupporters(target: SourceEntry, history: Iterable[InGameActivity], kill: Kill, bep: Long) extends Command + + object RewardOurSupporters { + def apply(obj: PlanetSideGameObject with FactionAffinity with InGameHistory, kill: Kill): RewardOurSupporters = { + RewardOurSupporters(SourceEntry(obj), obj.History, kill, kill.experienceEarned) + } + } +} + +class SupportExperienceCalculator(context: ActorContext[SupportExperienceCalculator.Command], zone: Zone) + extends AbstractBehavior[SupportExperienceCalculator.Command](context) { + + import SupportExperienceCalculator._ + + def onMessage(msg: Command): Behavior[Command] = { + msg match { + case RewardOurSupporters(target: PlayerSource, history, kill, bep) => + KillContributions.rewardTheseSupporters(target, history, kill, bep, zone.AvatarEvents) + + case _ => () + } + Behaviors.same + } +} diff --git a/src/main/scala/net/psforever/objects/zones/exp/ToDatabase.scala b/src/main/scala/net/psforever/objects/zones/exp/ToDatabase.scala new file mode 100644 index 000000000..fc9801c2b --- /dev/null +++ b/src/main/scala/net/psforever/objects/zones/exp/ToDatabase.scala @@ -0,0 +1,217 @@ +// Copyright (c) 2022 PSForever +package net.psforever.objects.zones.exp + +import scala.concurrent.ExecutionContext.Implicits.global +import net.psforever.objects.avatar.scoring.EquipmentStat +import net.psforever.objects.serverobject.hackable.Hackable.HackInfo +import net.psforever.objects.sourcing.VehicleSource +import net.psforever.persistence +import net.psforever.types.Vector3 +import net.psforever.util.Database.ctx +import net.psforever.util.Database.ctx._ + +object ToDatabase { + /** + * Insert an entry into the database's `killactivity` table. + * One player just died and some other player is at fault. + */ + def reportKillBy( + killerId: Long, + victimId: Long, + victimExoSuitId: Int, + victimMounted: Int, + weaponId: Int, + zoneId: Int, + position: Vector3, + exp: Long + ): Unit = { + ctx.run(query[persistence.Killactivity] + .insert( + _.victimId -> lift(victimId), + _.killerId -> lift(killerId), + _.victimExosuit -> lift(victimExoSuitId), + _.victimMounted -> lift(victimMounted), + _.weaponId -> lift(weaponId), + _.zoneId -> lift(zoneId), + _.px -> lift((position.x * 1000).toInt), + _.py -> lift((position.y * 1000).toInt), + _.pz -> lift((position.z * 1000).toInt), + _.exp -> lift(exp) + ) + ) + } + + /** + * Insert an entry into the database's `assistactivity` table. + * One player just died and some other player tried to take credit. + * (They are actually an accomplice.) + */ + def reportKillAssistBy( + avatarId: Long, + victimId: Long, + weaponId: Int, + zoneId: Int, + position: Vector3, + exp: Long + ): Unit = { + ctx.run(query[persistence.Killactivity] + .insert( + _.killerId -> lift(avatarId), + _.victimId -> lift(victimId), + _.weaponId -> lift(weaponId), + _.zoneId -> lift(zoneId), + _.px -> lift((position.x * 1000).toInt), + _.py -> lift((position.y * 1000).toInt), + _.pz -> lift((position.z * 1000).toInt), + _.exp -> lift(exp) + ) + ) + } + + /** + * Insert an entry into the database's `supportactivity` table. + * One player did something for some other player and + * that other player was able to kill a third player. + */ + def reportSupportBy( + user: Long, + target: Long, + exosuit: Int, + interaction: Int, + intermediate: Int, + implement: Int, + experience: Long + ): Unit = { + ctx.run(query[persistence.Supportactivity] + .insert( + _.userId -> lift(user), + _.targetId -> lift(target), + _.targetExosuit -> lift(exosuit), + _.interactionType -> lift(interaction), + _.implementType -> lift(implement), + _.intermediateType -> lift(intermediate), + _.exp -> lift(experience) + ) + ) + } + + /** + * Attempt to update the database's `weaponstatsession` table and, + * if no existing entries can be found, + * insert a new entry into the table. + * Shots fired. + */ + def reportToolDischarge(avatarId: Long, stats: EquipmentStat): Unit = { + ctx.run(query[persistence.Weaponstatsession] + .insert( + _.avatarId -> lift(avatarId), + _.weaponId -> lift(stats.objectId), + _.shotsFired -> lift(stats.shotsFired), + _.shotsLanded -> lift(stats.shotsLanded), + _.kills -> lift(0), + _.assists -> lift(0), + _.sessionId -> lift(-1L) + ) + .onConflictUpdate(_.avatarId, _.weaponId, _.sessionId)( + (t, e) => t.shotsFired -> (t.shotsFired + e.shotsFired), + (t, e) => t.shotsLanded -> (t.shotsLanded + e.shotsLanded) + ) + ) + } + + /** + * Insert an entry into the database's `machinedestroyed` table. + * Just as stated, something that was not a player was destroyed. + * Valid entity types include: vehicles, amenities, and various turrets. + */ + def reportMachineDestruction( + avatarId: Long, + machine: VehicleSource, + hackState: Option[HackInfo], + isCargo: Boolean, + weaponId: Int, + zoneNumber: Int + ): Unit = { + import net.psforever.util.Database.ctx + import net.psforever.util.Database.ctx._ + val normalFaction = machine.Faction.id + val hackedToFaction = hackState.map { _.player.Faction.id }.getOrElse(normalFaction) + val machinePosition = machine.Position + ctx.run(query[persistence.Machinedestroyed] + .insert( + _.avatarId -> lift(avatarId), + _.weaponId -> lift(weaponId), + _.machineType -> lift(machine.Definition.ObjectId), + _.machineFaction -> lift(normalFaction), + _.hackedFaction -> lift(hackedToFaction), + _.asCargo -> lift(isCargo), + _.zoneNum -> lift(zoneNumber), + _.px -> lift((machinePosition.x * 1000).toInt), + _.py -> lift((machinePosition.y * 1000).toInt), + _.pz -> lift((machinePosition.z * 1000).toInt) + ) + ) + } + + /** + * Insert an entry into the database's `ntuactivity` table. + * This table monitors experience earned through NTU silo operations and + * first time event entity interactions (zone and building set to 0). + */ + def reportNtuActivity( + avatarId: Long, + zoneId: Int, + buildingId: Int, + experience: Long + ): Unit = { + ctx.run(query[persistence.Ntuactivity] + .insert( + _.avatarId -> lift(avatarId), + _.zoneId -> lift(zoneId), + _.buildingId -> lift(buildingId), + _.exp -> lift(experience) + ) + .onConflictUpdate(_.avatarId, _.zoneId, _.buildingId)( + (t, e) => t.exp -> (t.exp + e.exp) + ) + ) + } + + /** + * Insert an entry into the database's `kdasession` table + * to specifically update the revive counter for the current session. + */ + def reportRespawns( + avatarId: Long, + reviveCount: Int + ): Unit = { + ctx.run(query[persistence.Kdasession] + .insert( + _.avatarId -> lift(avatarId), + _.revives -> lift(reviveCount), + _.sessionId -> lift(-1) + ) + ) + } + + /** + * Insert an entry into the database's `buildingCapture` table. + */ + def reportFacilityCapture( + avatarId: Long, + zoneId: Int, + buildingId: Int, + exp: Long, + expType: String + ): Unit = { + ctx.run(query[persistence.Buildingcapture] + .insert( + _.avatarId -> lift(avatarId), + _.zoneId -> lift(zoneId), + _.buildingId -> lift(buildingId), + _.exp -> lift(exp), + _.expType -> lift(expType) + ) + ) + } +} diff --git a/src/main/scala/net/psforever/objects/zones/exp/VitalsHistoryException.scala b/src/main/scala/net/psforever/objects/zones/exp/VitalsHistoryException.scala deleted file mode 100644 index e5fd7e67e..000000000 --- a/src/main/scala/net/psforever/objects/zones/exp/VitalsHistoryException.scala +++ /dev/null @@ -1,10 +0,0 @@ -// 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/objects/zones/exp/rec/ArmorRecoveryExperienceContributionProcess.scala b/src/main/scala/net/psforever/objects/zones/exp/rec/ArmorRecoveryExperienceContributionProcess.scala new file mode 100644 index 000000000..4927b9863 --- /dev/null +++ b/src/main/scala/net/psforever/objects/zones/exp/rec/ArmorRecoveryExperienceContributionProcess.scala @@ -0,0 +1,55 @@ +// Copyright (c) 2023 PSForever +package net.psforever.objects.zones.exp.rec + +import net.psforever.objects.sourcing.SourceUniqueness +import net.psforever.objects.vital.{DamagingActivity, InGameActivity, RepairFromEquipment, RepairingActivity} +import net.psforever.types.PlanetSideEmpire + +class ArmorRecoveryExperienceContributionProcess( + private val faction : PlanetSideEmpire.Value, + private val contributions: Map[SourceUniqueness, List[InGameActivity]] + ) extends RecoveryExperienceContributionProcess(faction, contributions) { + def submit(history: List[InGameActivity]): Unit = { + history.foreach { + case d: DamagingActivity if d.amount - d.health > 0 => + val (damage, recovery) = RecoveryExperienceContribution.contributeWithDamagingActivity( + d, + d.amount - d.health, + damageParticipants, + participants, + damageInOrder, + recoveryInOrder + ) + damageInOrder = damage + recoveryInOrder = recovery + case r: RepairFromEquipment => + val (damage, recovery) = RecoveryExperienceContribution.contributeWithRecoveryActivity( + r.user, + r.equipment_def.ObjectId, + faction, + r.amount, + r.time, + damageParticipants, + participants, + damageInOrder, + recoveryInOrder + ) + damageInOrder = damage + recoveryInOrder = recovery + case r: RepairingActivity => + val (damage, recovery) = RecoveryExperienceContribution.contributeWithRecoveryActivity( + wepid = 0, + faction, + r.amount, + r.time, + damageParticipants, + participants, + damageInOrder, + recoveryInOrder + ) + damageInOrder = damage + recoveryInOrder = recovery + case _ => () + } + } +} diff --git a/src/main/scala/net/psforever/objects/zones/exp/rec/CombinedHealthAndArmorContributionProcess.scala b/src/main/scala/net/psforever/objects/zones/exp/rec/CombinedHealthAndArmorContributionProcess.scala new file mode 100644 index 000000000..59b9a2089 --- /dev/null +++ b/src/main/scala/net/psforever/objects/zones/exp/rec/CombinedHealthAndArmorContributionProcess.scala @@ -0,0 +1,90 @@ +// Copyright (c) 2023 PSForever +package net.psforever.objects.zones.exp.rec + +import net.psforever.objects.sourcing.SourceUniqueness +import net.psforever.objects.vital.InGameActivity +import net.psforever.objects.zones.exp.{ContributionStats, KillContributions, Support, WeaponStats} +import net.psforever.types.PlanetSideEmpire + +import scala.collection.mutable + +class CombinedHealthAndArmorContributionProcess( + private val faction : PlanetSideEmpire.Value, + private val contributions: Map[SourceUniqueness, List[InGameActivity]], + otherSubmissions: Seq[RecoveryExperienceContribution] + ) extends RecoveryExperienceContribution { + private val process: Seq[RecoveryExperienceContributionProcess] = Seq( + new HealthRecoveryExperienceContributionProcess(faction, contributions), + new ArmorRecoveryExperienceContributionProcess(faction, contributions) + ) + + def submit(history: List[InGameActivity]): Unit = { + for (elem <- process ++ otherSubmissions) { elem.submit(history) } + } + + def output(): mutable.LongMap[ContributionStats] = { + val output = combineRecoveryContributions( + KillContributions.cullContributorImplements(process.head.output()), + KillContributions.cullContributorImplements(process(1).output()) + ) + clear() + output + } + + def clear(): Unit = { + process.foreach ( _.clear() ) + } + + private def combineRecoveryContributions( + healthAssists: mutable.LongMap[ContributionStats], + armorAssists: mutable.LongMap[ContributionStats] + ): mutable.LongMap[ContributionStats] = { + healthAssists + .map { + case out@(id, healthEntry) => + armorAssists.get(id) match { + case Some(armorEntry) => + //healthAssists && armorAssists + (id, healthEntry.copy(weapons = healthEntry.weapons ++ armorEntry.weapons)) + case None => + //healthAssists only + out + } + } + .addAll { + //armorAssists only + val healthKeys = healthAssists.keys.toSeq + armorAssists.filter { case (id, _) => !healthKeys.contains(id) } + } + .map { + case (id, entry) => + var totalShots: Int = 0 + var totalAmount: Int = 0 + var mostRecentTime: Long = 0 + val groupedWeapons = entry.weapons + .groupBy(_.equipment) + .map { + case (weaponContext, weaponEntries) => + val specificEntries = weaponEntries.filter(_.equipment == weaponContext) + val amount = specificEntries.foldLeft(0)(_ + _.amount) + totalAmount = totalAmount + amount + val shots = specificEntries.foldLeft(0)(_ + _.shots) + totalShots = totalShots + shots + val time = specificEntries.maxBy(_.time).time + mostRecentTime = math.max(mostRecentTime, time) + Support.calculateSupportExperience( + event = "support-heal", + WeaponStats(weaponContext, amount, shots, time, 1f) + ) + } + .toSeq + (id, entry.copy( + weapons = groupedWeapons, + amount = totalAmount, + total = math.max(entry.total, totalAmount), + shots = totalShots, + time = mostRecentTime + )) + } + } +} diff --git a/src/main/scala/net/psforever/objects/zones/exp/rec/HealthRecoveryExperienceContributionProcess.scala b/src/main/scala/net/psforever/objects/zones/exp/rec/HealthRecoveryExperienceContributionProcess.scala new file mode 100644 index 000000000..e639d7625 --- /dev/null +++ b/src/main/scala/net/psforever/objects/zones/exp/rec/HealthRecoveryExperienceContributionProcess.scala @@ -0,0 +1,68 @@ +// Copyright (c) 2023 PSForever +package net.psforever.objects.zones.exp.rec + +import net.psforever.objects.sourcing.SourceUniqueness +import net.psforever.objects.vital.{DamagingActivity, HealFromEquipment, HealingActivity, InGameActivity, RevivingActivity} +import net.psforever.types.PlanetSideEmpire + +private class HealthRecoveryExperienceContributionProcess( + private val faction : PlanetSideEmpire.Value, + private val contributions: Map[SourceUniqueness, List[InGameActivity]] + ) extends RecoveryExperienceContributionProcess(faction, contributions) { + def submit(history: List[InGameActivity]): Unit = { + history.foreach { + case d: DamagingActivity if d.health > 0 => + val (damage, recovery) = RecoveryExperienceContribution.contributeWithDamagingActivity( + d, + d.health, + damageParticipants, + participants, + damageInOrder, + recoveryInOrder + ) + damageInOrder = damage + recoveryInOrder = recovery + case h: HealFromEquipment => + val (damage, recovery) = RecoveryExperienceContribution.contributeWithRecoveryActivity( + h.user, + h.equipment_def.ObjectId, + faction, + h.amount, + h.time, + damageParticipants, + participants, + damageInOrder, + recoveryInOrder + ) + damageInOrder = damage + recoveryInOrder = recovery + case h: HealingActivity => + val (damage, recovery) = RecoveryExperienceContribution.contributeWithRecoveryActivity( + wepid = 0, + faction, + h.amount, + h.time, + damageParticipants, + participants, + damageInOrder, + recoveryInOrder + ) + damageInOrder = damage + recoveryInOrder = recovery + case r: RevivingActivity => + val (damage, recovery) = RecoveryExperienceContribution.contributeWithRecoveryActivity( + r.equipment.ObjectId, + faction, + r.amount, + r.time, + damageParticipants, + participants, + damageInOrder, + recoveryInOrder + ) + damageInOrder = damage + recoveryInOrder = recovery + case _ => () + } + } +} diff --git a/src/main/scala/net/psforever/objects/zones/exp/rec/MachineRecoveryExperienceContributionProcess.scala b/src/main/scala/net/psforever/objects/zones/exp/rec/MachineRecoveryExperienceContributionProcess.scala new file mode 100644 index 000000000..c1544fa46 --- /dev/null +++ b/src/main/scala/net/psforever/objects/zones/exp/rec/MachineRecoveryExperienceContributionProcess.scala @@ -0,0 +1,77 @@ +// Copyright (c) 2023 PSForever +package net.psforever.objects.zones.exp.rec + +import net.psforever.objects.sourcing.SourceUniqueness +import net.psforever.objects.vital.{DamagingActivity, InGameActivity, RepairFromEquipment, RepairingActivity} +import net.psforever.objects.zones.exp.{ContributionStats, Support, WeaponStats} +import net.psforever.types.PlanetSideEmpire + +import scala.collection.mutable + +class MachineRecoveryExperienceContributionProcess( + private val faction : PlanetSideEmpire.Value, + private val contributions: Map[SourceUniqueness, List[InGameActivity]], + eventOutputType: String, + private val excludedTargets: mutable.ListBuffer[SourceUniqueness] = mutable.ListBuffer() + ) extends RecoveryExperienceContributionProcess(faction, contributions) { + def submit(history: List[InGameActivity]): Unit = { + history.foreach { + case d: DamagingActivity if d.amount == d.health => + val (damage, recovery) = RecoveryExperienceContribution.contributeWithDamagingActivity( + d, + d.health, + damageParticipants, + participants, + damageInOrder, + recoveryInOrder + ) + damageInOrder = damage + recoveryInOrder = recovery + case r: RepairFromEquipment if !excludedTargets.contains(r.user.unique) => + val (damage, recovery) = RecoveryExperienceContribution.contributeWithRecoveryActivity( + r.user, + r.equipment_def.ObjectId, + faction, + r.amount, + r.time, + damageParticipants, + participants, + damageInOrder, + recoveryInOrder + ) + damageInOrder = damage + recoveryInOrder = recovery + case r: RepairingActivity => + val (damage, recovery) = RecoveryExperienceContribution.contributeWithRecoveryActivity( + wepid = 0, + faction, + r.amount, + r.time, + damageParticipants, + participants, + damageInOrder, + recoveryInOrder + ) + damageInOrder = damage + recoveryInOrder = recovery + case _ => () + } + } + + override def output(): mutable.LongMap[ContributionStats] = { + super.output().map { case (id, stats) => + val weps = stats.weapons + .groupBy(_.equipment) + .map { case (wrapper, entries) => + val size = entries.size + val newTime = entries.maxBy(_.time).time + Support.calculateSupportExperience( + eventOutputType, + WeaponStats(wrapper, size, entries.foldLeft(0)(_ + _.amount), newTime, 1f) + ) + } + .toSeq + (id, stats.copy(weapons = weps)) + } + } +} diff --git a/src/main/scala/net/psforever/objects/zones/exp/rec/RecoveryExperienceContribution.scala b/src/main/scala/net/psforever/objects/zones/exp/rec/RecoveryExperienceContribution.scala new file mode 100644 index 000000000..affab6e76 --- /dev/null +++ b/src/main/scala/net/psforever/objects/zones/exp/rec/RecoveryExperienceContribution.scala @@ -0,0 +1,282 @@ +// Copyright (c) 2023 PSForever +package net.psforever.objects.zones.exp.rec + +import net.psforever.objects.sourcing.PlayerSource +import net.psforever.objects.vital._ +import net.psforever.objects.vital.interaction.{Adversarial, DamageResult} +import net.psforever.objects.zones.exp.{ContributionStats, HealKillAssist, WeaponStats} +import net.psforever.types.PlanetSideEmpire + +import scala.collection.mutable + +trait RecoveryExperienceContribution { + def submit(history: List[InGameActivity]): Unit + def output(): mutable.LongMap[ContributionStats] + def clear(): Unit +} + +object RecoveryExperienceContribution { + private[exp] def contributeWithDamagingActivity( + activity: DamagingActivity, + amount: Int, + damageParticipants: mutable.LongMap[PlayerSource], + recoveryParticipants: mutable.LongMap[ContributionStats], + damageOrder: Seq[(Long, Int)], + recoveryOrder: Seq[(Long, Int)] + ): (Seq[(Long, Int)], Seq[(Long, Int)]) = { + //mark entries from the ordered recovery list to truncate + val data: DamageResult = activity.data + val time: Long = activity.time + var lastCharId: Long = 0L + var lastValue: Int = 0 + var ramt: Int = amount + var rindex: Int = 0 + val riter = recoveryOrder.iterator + while (riter.hasNext && ramt > 0) { + val (id, value) = riter.next() + if (value > 0) { + /* + if the amount on the previous recovery node is positive, reduce it by the damage value for that user's last used equipment + keep traversing recovery nodes, and lobbing them off, until the recovery amount is zero + if the user can not be found having an entry, skip the update but lob off the recovery progress node all the same + if the amount is zero, do not check any further recovery progress nodes + */ + recoveryParticipants + .get(id) + .foreach { entry => + val weapons = entry.weapons + lastCharId = id + lastValue = value + if (value > ramt) { + //take from the value on the last-used equipment, at the front of the list + recoveryParticipants.put( + id, + entry.copy( + weapons = weapons.head.copy(amount = math.max(0, weapons.head.amount - ramt), time = time) +: weapons.tail, + amount = math.max(0, entry.amount - ramt), + time = time + ) + ) + ramt = 0 + lastValue = lastValue - value + } else { + //take from the value on the last-used equipment, at the front of the list + //move that entry to the end of the list + recoveryParticipants.put( + id, + entry.copy( + weapons = weapons.tail :+ weapons.head.copy(amount = 0, time = time), + amount = math.max(0, entry.amount - ramt), + time = time + ) + ) + ramt = ramt - value + rindex += 1 + lastValue = 0 + } + } + rindex += 1 + } + } + //damage order and damage contribution entry + val newDamageEntry = data + .adversarial + .collect { case Adversarial(p: PlayerSource, _, _) => (p, damageParticipants.get(p.CharId)) } + .collect { + case (player, Some(PlayerSource.Nobody)) => + damageParticipants.put(player.CharId, player) + Some(player) + case (player, Some(_)) => + damageParticipants.getOrElseUpdate(player.CharId, player) + Some(player) + } + .collect { + case Some(player) => (player.CharId, amount) //for damageOrder + } + .orElse { + Some((0L, amount)) //for damageOrder + } + //re-combine output list(s) + val leftovers = if (lastValue > 0) { + Seq((lastCharId, lastValue)) + } else { + Nil + } + (newDamageEntry.toList ++ damageOrder, leftovers ++ recoveryOrder.slice(rindex, recoveryOrder.size) ++ recoveryOrder.take(rindex).map { case (id, _) => (id, 0) }) + } + + private[exp] def contributeWithRecoveryActivity( + user: PlayerSource, + wepid: Int, + faction: PlanetSideEmpire.Value, + amount: Int, + time: Long, + damageParticipants: mutable.LongMap[PlayerSource], + recoveryParticipants: mutable.LongMap[ContributionStats], + damageOrder: Seq[(Long, Int)], + recoveryOrder: Seq[(Long, Int)] + ): (Seq[(Long, Int)], Seq[(Long, Int)]) = { + contributeWithRecoveryActivity(user, user.CharId, wepid, faction, amount, time, damageParticipants, recoveryParticipants, damageOrder, recoveryOrder) + } + + private[exp] def contributeWithRecoveryActivity( + wepid: Int, + faction: PlanetSideEmpire.Value, + amount: Int, + time: Long, + damageParticipants: mutable.LongMap[PlayerSource], + recoveryParticipants: mutable.LongMap[ContributionStats], + damageOrder: Seq[(Long, Int)], + recoveryOrder: Seq[(Long, Int)] + ): (Seq[(Long, Int)], Seq[(Long, Int)]) = { + contributeWithRecoveryActivity(PlayerSource.Nobody, charId = 0, wepid, faction, amount, time, damageParticipants, recoveryParticipants, damageOrder, recoveryOrder) + } + + private[exp] def contributeWithRecoveryActivity( + user: PlayerSource, + charId: Long, + wepid: Int, + faction: PlanetSideEmpire.Value, + amount: Int, + time: Long, + damageParticipants: mutable.LongMap[PlayerSource], + recoveryParticipants: mutable.LongMap[ContributionStats], + damageOrder: Seq[(Long, Int)], + recoveryOrder: Seq[(Long, Int)] + ): (Seq[(Long, Int)], Seq[(Long, Int)]) = { + //mark entries from the ordered damage list to truncate + val damageEntries = damageOrder.iterator + var amtToReduce: Int = amount + var amtToGain: Int = 0 + var lastValue: Int = -1 + var damageRemoveCount: Int = 0 + var damageRemainder: Seq[(Long, Int)] = Nil + //keep reducing previous damage until recovery amount is depleted, or no more damage entries remain, or the last damage entry was depleted already + while (damageEntries.hasNext && amtToReduce > 0 && lastValue != 0) { + val (id, value) = damageEntries.next() + lastValue = value + if (value > 0) { + damageParticipants + .get(id) + .collect { + case player if player.Faction != faction => + //if previous attacker was an enemy, the recovery counts towards contribution + if (value > amtToReduce) { + damageRemainder = Seq((id, value - amtToReduce)) + amtToGain = amtToGain + amtToReduce + amtToReduce = 0 + } else { + amtToGain = amtToGain + value + amtToReduce = amtToReduce - value + } + Some(player) + case player => + //if the previous attacker was friendly fire, the recovery doesn't count towards contribution + if (value > amtToReduce) { + damageRemainder = Seq((id, value - amtToReduce)) + amtToReduce = 0 + } else { + amtToReduce = amtToReduce - value + } + Some(player) + } + .orElse { + //if we couldn't find an entry, just give the contribution to the user anyway + damageParticipants.put(id, user) + if (value > amtToReduce) { + damageRemainder = Seq((id, value - amtToReduce)) + amtToGain = amtToGain + amtToReduce + amtToReduce = 0 + } else { + amtToGain = amtToGain + value + amtToReduce = amtToReduce - value + } + None + } + //keep track of entries whose damage was depleted + damageRemoveCount += 1 + } + } + amtToGain = amtToGain + amtToReduce //if early termination, gives leftovers as gain + if (amtToGain > 0) { + val newWeaponStats = WeaponStats(HealKillAssist(wepid), amtToGain, 1, time, 1f) + //try: add first contribution entry + //then: add accumulation of last weapon entry to contribution entry + //last: add new weapon entry to contribution entry + recoveryParticipants + .getOrElseUpdate( + charId, + ContributionStats(user, Seq(newWeaponStats), amtToGain, amtToGain, 1, time) + ) match { + case entry if entry.weapons.size > 1 => + if (entry.weapons.head.equipment.equipment == wepid) { + val head = entry.weapons.head + recoveryParticipants.put( + charId, + entry.copy( + weapons = head.copy(amount = head.amount + amtToGain, shots = head.shots + 1, time = time) +: entry.weapons.tail, + amount = entry.amount + amtToGain, + total = entry.total + amtToGain, + shots = entry.shots + 1, + time = time + ) + ) + } else { + recoveryParticipants.put( + charId, + entry.copy( + weapons = newWeaponStats +: entry.weapons, + amount = entry.amount + amtToGain, + total = entry.total + amtToGain, + shots = entry.shots + 1, + time = time + ) + ) + } + case _ => () + //not technically possible + } + } + val newRecoveryEntry = if (amtToGain == 0) { + Seq((0L, amount)) + } else if (amtToGain < amount) { + Seq((0L, amount - amtToGain), (charId, amtToGain)) + } else { + Seq((charId, amount)) + } + ( + damageRemainder ++ damageOrder.drop(damageRemoveCount) ++ damageOrder.take(damageRemoveCount).map { case (id, _) => (id, 0) }, + newRecoveryEntry ++ recoveryOrder + ) + } + + private[exp] def contributeWithSupportRecoveryActivity( + users: Seq[PlayerSource], + wepid: Int, + faction: PlanetSideEmpire.Value, + amount: Int, + time: Long, + participants: mutable.LongMap[ContributionStats], + damageOrder: Seq[(Long, Int)], + recoveryOrder: Seq[(Long, Int)] + ): (Seq[(Long, Int)], Seq[(Long, Int)]) = { + var outputDamageOrder = damageOrder + var outputRecoveryOrder = recoveryOrder + if (users.nonEmpty) { + val damageParticipants: mutable.LongMap[PlayerSource] = mutable.LongMap[PlayerSource]() + users.zip { + val numberOfUsers = users.size + val out = Array.fill(numberOfUsers)(numberOfUsers / amount) + (0 to numberOfUsers % amount).foreach { + out(_) += 1 + } + out + }.foreach { case (user, subAmount) => + val (a, b) = contributeWithRecoveryActivity(user, user.CharId, wepid, faction, subAmount, time, damageParticipants, participants, outputDamageOrder, outputRecoveryOrder) + outputDamageOrder = a + outputRecoveryOrder = b + } + } + (outputDamageOrder, outputRecoveryOrder) + } +} diff --git a/src/main/scala/net/psforever/objects/zones/exp/rec/RecoveryExperienceContributionProcess.scala b/src/main/scala/net/psforever/objects/zones/exp/rec/RecoveryExperienceContributionProcess.scala new file mode 100644 index 000000000..e6f3ecc82 --- /dev/null +++ b/src/main/scala/net/psforever/objects/zones/exp/rec/RecoveryExperienceContributionProcess.scala @@ -0,0 +1,37 @@ +// Copyright (c) 2023 PSForever +package net.psforever.objects.zones.exp.rec + +import net.psforever.objects.sourcing.{PlayerSource, SourceUniqueness} +import net.psforever.objects.vital.InGameActivity +import net.psforever.objects.zones.exp.ContributionStats +import net.psforever.types.PlanetSideEmpire + +import scala.collection.mutable + +//noinspection ScalaUnusedSymbol +abstract class RecoveryExperienceContributionProcess( + faction : PlanetSideEmpire.Value, + contributions: Map[SourceUniqueness, List[InGameActivity]] + ) extends RecoveryExperienceContribution { + protected var damageInOrder: Seq[(Long, Int)] = Seq[(Long, Int)]() + protected var recoveryInOrder: Seq[(Long, Int)] = Seq[(Long, Int)]() + protected val contributionsBy: mutable.LongMap[ContributionStats] = mutable.LongMap[ContributionStats]() + protected val participants: mutable.LongMap[ContributionStats] = mutable.LongMap[ContributionStats]() + protected val damageParticipants: mutable.LongMap[PlayerSource] = mutable.LongMap[PlayerSource]() + + def submit(history: List[InGameActivity]): Unit + + def output(): mutable.LongMap[ContributionStats] = { + val output = participants.map { a => a } + clear() + output + } + + def clear(): Unit = { + damageInOrder = Nil + recoveryInOrder = Nil + contributionsBy.clear() + participants.clear() + damageParticipants.clear() + } +} diff --git a/src/main/scala/net/psforever/packet/game/AvatarStatisticsMessage.scala b/src/main/scala/net/psforever/packet/game/AvatarStatisticsMessage.scala index e91d13631..47ad60536 100644 --- a/src/main/scala/net/psforever/packet/game/AvatarStatisticsMessage.scala +++ b/src/main/scala/net/psforever/packet/game/AvatarStatisticsMessage.scala @@ -14,6 +14,7 @@ import scala.annotation.switch * na */ sealed abstract class Statistic(val code: Int) + /** * na */ @@ -38,12 +39,18 @@ sealed case class IntermediateStatistic( * @param fields four pairs of values that add together to produce the first columns on the statistics spreadsheet; * organized as TR, NC, VS, BO (PS) */ -final case class InitStatistic( +final case class CampaignStatistic( category: StatisticalCategory, unk: StatisticalElement, fields: List[Long] ) extends Statistic(code = 0) with ComplexStatistic +object CampaignStatistic { + def apply(cat: StatisticalCategory, stat: StatisticalElement, tr: Long, nc: Long, vs: Long, bo: Long): CampaignStatistic = { + CampaignStatistic(cat, stat, List(tr, 0, nc, 0, vs, 0, bo, 0)) + } +} + /** * * @param category na @@ -51,12 +58,18 @@ final case class InitStatistic( * @param fields four pairs of values that add together to produce the first column(s) on the statistics spreadsheet; * organized as TR, NC, VS, BO (PS) */ -final case class UpdateStatistic( +final case class SessionStatistic( category: StatisticalCategory, unk: StatisticalElement, fields: List[Long] ) extends Statistic(code = 1) with ComplexStatistic +object SessionStatistic { + def apply(cat: StatisticalCategory, stat: StatisticalElement, tr: Long, nc: Long, vs: Long, bo: Long): SessionStatistic = { + SessionStatistic(cat, stat, List(0, tr, 0, nc, 0, vs, 0, bo)) + } +} + /** * * @param deaths how badly you suck, quantitatively analyzed @@ -103,10 +116,10 @@ object AvatarStatisticsMessage extends Marshallable[AvatarStatisticsMessage] { */ private val initCodec: Codec[Statistic] = complexCodec.exmap[Statistic]( { - case IntermediateStatistic(a, b, c) => Successful(InitStatistic(a, b, c)) + case IntermediateStatistic(a, b, c) => Successful(CampaignStatistic(a, b, c)) }, { - case InitStatistic(a, b, c) => Successful(IntermediateStatistic(a, b, c)) + case CampaignStatistic(a, b, c) => Successful(IntermediateStatistic(a, b, c)) case _ => Failure(Err("expected initializing statistic, but found something else")) } ) @@ -115,10 +128,10 @@ object AvatarStatisticsMessage extends Marshallable[AvatarStatisticsMessage] { */ private val updateCodec: Codec[Statistic] = complexCodec.exmap[Statistic]( { - case IntermediateStatistic(a, b, c) => Successful(UpdateStatistic(a, b, c)) + case IntermediateStatistic(a, b, c) => Successful(SessionStatistic(a, b, c)) }, { - case UpdateStatistic(a, b, c) => Successful(IntermediateStatistic(a, b, c)) + case SessionStatistic(a, b, c) => Successful(IntermediateStatistic(a, b, c)) case _ => Failure(Err("expected updating statistic, but found something else")) } ) diff --git a/src/main/scala/net/psforever/packet/game/MailMessage.scala b/src/main/scala/net/psforever/packet/game/MailMessage.scala index 9c4cc2656..f584fb6e2 100644 --- a/src/main/scala/net/psforever/packet/game/MailMessage.scala +++ b/src/main/scala/net/psforever/packet/game/MailMessage.scala @@ -15,7 +15,8 @@ import scodec.codecs._ * At the moment, it only seems possible to receive and read mail from the server. * @param sender the name of the player who sent the mail * @param subject the subject - * @param message the message + * @param message the message; + * line breaks use `\n` */ final case class MailMessage(sender: String, subject: String, message: String) extends PlanetSideGamePacket { type Packet = MailMessage diff --git a/src/main/scala/net/psforever/persistence/Account.scala b/src/main/scala/net/psforever/persistence/Account.scala index 97a2382c3..090c12191 100644 --- a/src/main/scala/net/psforever/persistence/Account.scala +++ b/src/main/scala/net/psforever/persistence/Account.scala @@ -12,6 +12,8 @@ case class Account( inactive: Boolean = false, gm: Boolean = false, lastFactionId: Int = 3, + avatarLoggedIn: Long, + sessionId: Long, token: Option[String], tokenCreated: Option[LocalDateTime] ) diff --git a/src/main/scala/net/psforever/persistence/KdaExp.scala b/src/main/scala/net/psforever/persistence/KdaExp.scala new file mode 100644 index 000000000..66fcc0834 --- /dev/null +++ b/src/main/scala/net/psforever/persistence/KdaExp.scala @@ -0,0 +1,95 @@ +// Copyright (c) 2023 PSForever +package net.psforever.persistence + +import org.joda.time.LocalDateTime + +case class Assistactivity( + index: Int, + killerId: Long, + victimId: Long, + weaponId: Int, + zoneId: Int, + px: Int, //Position.x * 1000 + py: Int, //Position.y * 1000 + pz: Int, //Position.z * 1000 + exp: Long, + timestamp: LocalDateTime = LocalDateTime.now() + ) + +case class Buildingcapture( + index: Int, + avatarId: Long, + zoneId: Int, + buildingId: Int, + exp: Long, + expType: String, + timestamp: LocalDateTime = LocalDateTime.now() + ) + +case class Kdasession ( + avatarId: Long, + sessionId: Int, + kills: Int, + deaths: Int, + assists: Int, + revives: Int + ) + +case class Killactivity( + index: Int, + killerId: Long, + victimId: Long, + victimExosuit: Int, + victimMounted: Int, //object type id * 10 + seat type + weaponId: Int, + zoneId: Int, + px: Int, //Position.x * 1000 + py: Int, //Position.y * 1000 + pz: Int, //Position.z * 1000 + exp: Long, + timestamp: LocalDateTime = LocalDateTime.now() + ) + +case class Machinedestroyed( + index: Int, + avatarId: Long, + weaponId: Int, + machineType: Int, + machineFaction: Int, + hackedFaction: Int, + asCargo: Boolean, + zoneNum: Int, + px: Int, //Position.x * 1000 + py: Int, //Position.y * 1000 + pz: Int, //Position.z * 1000 + timestamp: LocalDateTime = LocalDateTime.now() + ) + +case class Ntuactivity ( + avatarId: Long, + zoneId: Int, + buildingId: Int, + exp: Long + ) + +case class Supportactivity( + index: Int, + userId: Long, + targetId: Long, + targetExosuit: Int, + interactionType: Int, + implementType: Int, + intermediateType: Int, + exp: Long, + timestamp: LocalDateTime = LocalDateTime.now() + ) + +case class Weaponstatsession( + avatarId: Long, + weaponId: Int, + shotsFired: Int, + shotsLanded: Int, + kills: Int, + assists: Int, + sessionId: Long + ) diff --git a/src/main/scala/net/psforever/persistence/Progressiondebt.scala b/src/main/scala/net/psforever/persistence/Progressiondebt.scala new file mode 100644 index 000000000..e81aebc4d --- /dev/null +++ b/src/main/scala/net/psforever/persistence/Progressiondebt.scala @@ -0,0 +1,7 @@ +// Copyright (c) 2023 PSForever +package net.psforever.persistence + +case class Progressiondebt( + avatarId:Long, + experience: Long + ) diff --git a/src/main/scala/net/psforever/services/RemoverActor.scala b/src/main/scala/net/psforever/services/RemoverActor.scala index 0348a3c1b..d6118dccc 100644 --- a/src/main/scala/net/psforever/services/RemoverActor.scala +++ b/src/main/scala/net/psforever/services/RemoverActor.scala @@ -82,7 +82,7 @@ abstract class RemoverActor() extends SupportActor[RemoverActor.Entry] { entryManagementBehaviors .orElse { case RemoverActor.AddTask(obj, zone, duration) => - val entry = RemoverActor.Entry(obj, zone, duration.getOrElse(FirstStandardDuration).toNanos) + val entry = RemoverActor.Entry(obj, zone, duration.getOrElse(FirstStandardDuration).toMillis) if (InclusionTest(entry) && !secondHeap.exists(test => sameEntryComparator.Test(test, entry))) { InitialJob(entry) if (entry.duration == 0) { @@ -120,7 +120,7 @@ abstract class RemoverActor() extends SupportActor[RemoverActor.Entry] { case RemoverActor.StartDelete() => firstTask.cancel() secondTask.cancel() - val now: Long = System.nanoTime + val now: Long = System.currentTimeMillis() val (in, out) = firstHeap.partition(entry => { now - entry.time >= entry.duration }) @@ -229,19 +229,19 @@ abstract class RemoverActor() extends SupportActor[RemoverActor.Entry] { * this new entry is always set to last for the duration of the second pool */ private def RepackageEntry(entry: RemoverActor.Entry): RemoverActor.Entry = { - RemoverActor.Entry(entry.obj, entry.zone, SecondStandardDuration.toNanos) + RemoverActor.Entry(entry.obj, entry.zone, SecondStandardDuration.toMillis) } /** * Common function to reset the first task's delayed execution. * Cancels the scheduled timer and will only restart the timer if there is at least one entry in the first pool. - * @param now the time (in nanoseconds); - * defaults to the current time (in nanoseconds) + * @param now the time (in milliseconds); + * defaults to the current time (in milliseconds) */ - def RetimeFirstTask(now: Long = System.nanoTime): Unit = { + def RetimeFirstTask(now: Long = System.currentTimeMillis()): Unit = { firstTask.cancel() if (firstHeap.nonEmpty) { - val short_timeout: FiniteDuration = math.max(1, firstHeap.head.duration - (now - firstHeap.head.time)) nanoseconds + val short_timeout: FiniteDuration = math.max(1, firstHeap.head.duration - (now - firstHeap.head.time)).milliseconds import scala.concurrent.ExecutionContext.Implicits.global firstTask = context.system.scheduler.scheduleOnce(short_timeout, self, RemoverActor.StartDelete()) } @@ -274,14 +274,14 @@ abstract class RemoverActor() extends SupportActor[RemoverActor.Entry] { /** * Default time for entries waiting in the first list. * Override. - * @return the time as a `FiniteDuration` object (to be later transformed into nanoseconds) + * @return the time as a `FiniteDuration` object (to be later transformed into milliseconds) */ def FirstStandardDuration: FiniteDuration /** * Default time for entries waiting in the second list. * Override. - * @return the time as a `FiniteDuration` object (to be later transformed into nanoseconds) + * @return the time as a `FiniteDuration` object (to be later transformed into milliseconds) */ def SecondStandardDuration: FiniteDuration @@ -322,7 +322,7 @@ object RemoverActor extends SupportActorCaseConversions { * Internally, all entries have a "time created" field. * @param _obj the target * @param _zone the zone in which this target is registered - * @param _duration how much longer the target will exist in its current state (in nanoseconds) + * @param _duration how much longer the target will exist in its current state (in milliseconds) */ case class Entry(_obj: PlanetSideGameObject, _zone: Zone, _duration: Long) extends SupportActor.Entry(_obj, _zone, _duration) @@ -332,7 +332,7 @@ object RemoverActor extends SupportActorCaseConversions { * @see `FirstStandardDuration` * @param obj the target * @param zone the zone in which this target is registered - * @param duration how much longer the target will exist in its current state (in nanoseconds); + * @param duration how much longer the target will exist in its current state (in milliseconds); * a default time duration is provided by implementation */ case class AddTask(obj: PlanetSideGameObject, zone: Zone, duration: Option[FiniteDuration] = None) diff --git a/src/main/scala/net/psforever/services/avatar/AvatarService.scala b/src/main/scala/net/psforever/services/avatar/AvatarService.scala index c560723a7..8ee7edb52 100644 --- a/src/main/scala/net/psforever/services/avatar/AvatarService.scala +++ b/src/main/scala/net/psforever/services/avatar/AvatarService.scala @@ -438,7 +438,34 @@ class AvatarService(zone: Zone) extends Actor { ) ) - case _ => ; + case AvatarAction.AwardBep(charId, bep, expType) => + AvatarEvents.publish( + AvatarServiceResponse( + s"/$forChannel/Avatar", + Service.defaultPlayerGUID, + AvatarResponse.AwardBep(charId, bep, expType) + ) + ) + + case AvatarAction.AwardCep(charId, bep) => + AvatarEvents.publish( + AvatarServiceResponse( + s"/$forChannel/Avatar", + Service.defaultPlayerGUID, + AvatarResponse.AwardCep(charId, bep) + ) + ) + + case AvatarAction.FacilityCaptureRewards(building_id, zone_number, exp) => + AvatarEvents.publish( + AvatarServiceResponse( + s"/$forChannel/Avatar", + Service.defaultPlayerGUID, + AvatarResponse.FacilityCaptureRewards(building_id, zone_number, exp) + ) + ) + + case _ => () } //message to Undertaker diff --git a/src/main/scala/net/psforever/services/avatar/AvatarServiceMessage.scala b/src/main/scala/net/psforever/services/avatar/AvatarServiceMessage.scala index 70574ef78..33d5b894f 100644 --- a/src/main/scala/net/psforever/services/avatar/AvatarServiceMessage.scala +++ b/src/main/scala/net/psforever/services/avatar/AvatarServiceMessage.scala @@ -12,7 +12,7 @@ import net.psforever.objects.sourcing.SourceEntry import net.psforever.objects.zones.Zone import net.psforever.packet.PlanetSideGamePacket import net.psforever.packet.game.objectcreate.{ConstructorData, ObjectCreateMessageParent} -import net.psforever.types.{ExoSuitType, PlanetSideEmpire, PlanetSideGUID, TransactionType, Vector3} +import net.psforever.types.{ExoSuitType, ExperienceType, PlanetSideEmpire, PlanetSideGUID, TransactionType, Vector3} import scala.concurrent.duration.FiniteDuration @@ -156,6 +156,9 @@ object AvatarAction { final case class KitNotUsed(kit_guid: PlanetSideGUID, msg: String) extends Action final case class UpdateKillsDeathsAssists(charId: Long, kda: KDAStat) extends Action + final case class AwardBep(charId: Long, bep: Long, expType: ExperienceType) extends Action + final case class AwardCep(charId: Long, bep: Long) extends Action + final case class FacilityCaptureRewards(building_id: Int, zone_number: Int, exp: Long) extends Action final case class TeardownConnection() extends Action // final case class PlayerStateShift(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 e3a98ee92..05ba1a1d5 100644 --- a/src/main/scala/net/psforever/services/avatar/AvatarServiceResponse.scala +++ b/src/main/scala/net/psforever/services/avatar/AvatarServiceResponse.scala @@ -11,7 +11,7 @@ import net.psforever.objects.sourcing.SourceEntry import net.psforever.packet.PlanetSideGamePacket import net.psforever.packet.game.objectcreate.ConstructorData import net.psforever.packet.game.ObjectCreateMessage -import net.psforever.types.{ExoSuitType, PlanetSideEmpire, PlanetSideGUID, TransactionType, Vector3} +import net.psforever.types.{ExoSuitType, ExperienceType, PlanetSideEmpire, PlanetSideGUID, TransactionType, Vector3} import net.psforever.services.GenericEventBusMsg final case class AvatarServiceResponse( @@ -127,4 +127,7 @@ object AvatarResponse { final case class KitNotUsed(kit_guid: PlanetSideGUID, msg: String) extends Response final case class UpdateKillsDeathsAssists(charId: Long, kda: KDAStat) extends Response + final case class AwardBep(charId: Long, bep: Long, expType: ExperienceType) extends Response + final case class AwardCep(charId: Long, bep: Long) extends Response + final case class FacilityCaptureRewards(building_id: Int, zone_number: Int, exp: Long) extends Response } diff --git a/src/main/scala/net/psforever/services/local/support/DoorCloseActor.scala b/src/main/scala/net/psforever/services/local/support/DoorCloseActor.scala index 3d25dcb41..115924267 100644 --- a/src/main/scala/net/psforever/services/local/support/DoorCloseActor.scala +++ b/src/main/scala/net/psforever/services/local/support/DoorCloseActor.scala @@ -49,8 +49,7 @@ class DoorCloseActor() extends Actor { }) if (openDoors.nonEmpty) { - val short_timeout: FiniteDuration = - math.max(1, DoorCloseActor.timeout_time - (now - openDoors.head.time)) nanoseconds + val short_timeout: FiniteDuration = math.max(1, DoorCloseActor.timeout_time - (now - openDoors.head.time)).milliseconds import scala.concurrent.ExecutionContext.Implicits.global doorCloserTrigger = context.system.scheduler.scheduleOnce(short_timeout, self, DoorCloseActor.TryCloseDoors()) } @@ -67,7 +66,7 @@ class DoorCloseActor() extends Actor { * and newer entries are always added to the end of the main `List`, * processing in order is always correct. * @param list the `List` of entries to divide - * @param now the time right now (in nanoseconds) + * @param now the time right now (in milliseconds) * @see `List.partition` * @return a `Tuple` of two `Lists`, whose qualifications are explained above */ @@ -84,7 +83,7 @@ class DoorCloseActor() extends Actor { * a `List` of elements that have exceeded the time limit, * and a `List` of elements that still satisfy the time limit. * @param iter the `Iterator` of entries to divide - * @param now the time right now (in nanoseconds) + * @param now the time right now (in milliseconds) * @param index a persistent record of the index where list division should occur; * defaults to 0 * @return the index where division will occur 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 c8cf0579a..9c8b9f642 100644 --- a/src/main/scala/net/psforever/services/local/support/HackCaptureActor.scala +++ b/src/main/scala/net/psforever/services/local/support/HackCaptureActor.scala @@ -9,19 +9,16 @@ import net.psforever.objects.serverobject.llu.CaptureFlag import net.psforever.objects.serverobject.structures.Building import net.psforever.objects.serverobject.terminals.capture.CaptureTerminal import net.psforever.objects.zones.Zone -import net.psforever.objects.{Default, Player} +import net.psforever.objects.Default import net.psforever.packet.game.{GenericAction, PlanetsideAttributeEnum} import net.psforever.objects.sourcing.PlayerSource -import net.psforever.objects.zones.ZoneHotSpotProjector import net.psforever.services.Service +import net.psforever.services.local.support.HackCaptureActor.GetHackingFaction import net.psforever.services.local.{LocalAction, LocalServiceMessage} import net.psforever.types.{PlanetSideEmpire, PlanetSideGUID} -import net.psforever.util.Config -import java.util.concurrent.TimeUnit -import scala.collection.mutable import scala.concurrent.duration.{FiniteDuration, _} -import scala.util.{Random, Success} +import scala.util.Random /** * Responsible for handling the aspects related to hacking control consoles and capturing bases. @@ -40,7 +37,7 @@ class HackCaptureActor extends Actor { val duration = target.Definition.FacilityHackTime target.HackedBy match { case Some(hackInfo) => - target.HackedBy = hackInfo.Duration(duration.toNanos) + target.HackedBy = hackInfo.Duration(duration.toMillis) case None => log.error(s"Initial $target hack information is missing") } @@ -59,8 +56,8 @@ class HackCaptureActor extends Actor { case HackCaptureActor.ProcessCompleteHacks() => log.trace("Processing complete hacks") clearTrigger.cancel() - val now: Long = System.nanoTime - val (stillHacked, finishedHacks) = hackedObjects.partition(x => now - x.hack_timestamp < x.duration.toNanos) + val now: Long = System.currentTimeMillis() + val (stillHacked, finishedHacks) = hackedObjects.partition(x => now - x.hack_timestamp < x.duration.toMillis) hackedObjects = stillHacked finishedHacks.foreach { entry => val terminal = entry.target @@ -79,14 +76,9 @@ class HackCaptureActor extends Actor { case _ => // Timed hack finished (or neutral LLU base with no neighbour as timed hack), capture the base + val hackTime = terminal.Definition.FacilityHackTime.toMillis HackCompleted(terminal, hackedByFaction) - HackCaptureActor.RewardFacilityCaptureParticipants( - building, - terminal, - hacker, - now - entry.hack_timestamp, - isResecured = false - ) + building.Participation.RewardFacilityCapture(terminal.Faction, hackedByFaction, hacker, hackTime, hackTime, isResecured = false) } } // If there's hacked objects left in the list restart the timer with the shortest hack time left @@ -94,6 +86,7 @@ class HackCaptureActor extends Actor { case HackCaptureActor.ResecureCaptureTerminal(target, _, hacker) => val (results, remainder) = hackedObjects.partition(x => x.target eq target) + val faction = GetHackingFaction(target).getOrElse(target.Faction) target.HackedBy = None hackedObjects = remainder val building = target.Owner.asInstanceOf[Building] @@ -102,13 +95,7 @@ class HackCaptureActor extends Actor { case flag: CaptureFlag => target.Zone.LocalEvents ! CaptureFlagManager.Lost(flag, CaptureFlagLostReasonEnum.Resecured) } NotifyHackStateChange(target, isResecured = true) -// HackCaptureActor.RewardFacilityCaptureParticipants( -// building, -// target, -// hacker, -// System.currentTimeMillis() - results.head.hack_timestamp, -// isResecured = true -// ) + building.Participation.RewardFacilityCapture(target.Faction, faction, hacker, target.Definition.FacilityHackTime.toMillis, System.currentTimeMillis() - results.head.hack_timestamp, isResecured = true) // Restart the timer in case the object we just removed was the next one scheduled RestartTimer() @@ -124,13 +111,7 @@ class HackCaptureActor extends Actor { val hackedByFaction = hackInfo.hackerFaction hackedObjects = hackedObjects.filterNot(x => x == entry) HackCompleted(terminal, hackedByFaction) -// HackCaptureActor.RewardFacilityCaptureParticipants( -// building, -// terminal, -// hacker, -// System.currentTimeMillis() - entry.hack_timestamp, -// isResecured = false -// ) + building.Participation.RewardFacilityCapture(terminal.Faction, hacker.Faction, hacker, terminal.Definition.FacilityHackTime.toMillis, System.currentTimeMillis() - entry.hack_timestamp, isResecured = false) entry.target.Actor ! CommonMessages.ClearHack() flag.Zone.LocalEvents ! CaptureFlagManager.Captured(flag) // If there's hacked objects left in the list restart the timer with the shortest hack time left @@ -151,6 +132,9 @@ class HackCaptureActor extends Actor { case owner: Building if owner.IsCtfBase => Some((owner, owner.GetFlag, owner.Neighbours(hackingFaction))) case _ => None }) match { + case None => + //not an error; this is just not a ctf facility + false case Some((owner, None, Some(neighbours))) if neighbours.nonEmpty => log.info(s"An LLU is being spawned for facility ${owner.Name} by $hackingFaction") spawnCaptureFlag(neighbours, terminal, hackingFaction) @@ -231,9 +215,8 @@ class HackCaptureActor extends Actor { private def RestartTimer(): Unit = { if (hackedObjects.nonEmpty) { - val hackEntry = hackedObjects.reduceLeft(HackCaptureActor.minTimeLeft(System.nanoTime())) - val short_timeout: FiniteDuration = - math.max(1, hackEntry.duration.toNanos - (System.nanoTime - hackEntry.hack_timestamp)).nanoseconds + val hackEntry = hackedObjects.reduceLeft(HackCaptureActor.minTimeLeft(System.currentTimeMillis())) + val short_timeout: FiniteDuration = math.max(1, hackEntry.duration.toMillis - (System.currentTimeMillis() - hackEntry.hack_timestamp)).milliseconds log.trace(s"RestartTimer: still items left in hacked objects list. Checking again in ${short_timeout.toSeconds} seconds") import scala.concurrent.ExecutionContext.Implicits.global clearTrigger = context.system.scheduler.scheduleOnce(short_timeout, self, HackCaptureActor.ProcessCompleteHacks()) @@ -247,7 +230,7 @@ object HackCaptureActor { zone: Zone, unk1: Long, unk2: Long, - startTime: Long = System.nanoTime() + startTime: Long = System.currentTimeMillis() ) final case class ResecureCaptureTerminal(target: CaptureTerminal, zone: Zone, hacker: PlayerSource) @@ -274,8 +257,7 @@ object HackCaptureActor { 17039360L case Some(Hackable.HackInfo(p, _, start, length)) => // See PlanetSideAttributeMessage #20 documentation for an explanation of how the timer is calculated - val hackTimeRemainingMS = - TimeUnit.MILLISECONDS.convert(math.max(0, start + length - System.nanoTime), TimeUnit.NANOSECONDS) + val hackTimeRemainingMS = math.max(0, start + length - System.currentTimeMillis()) val startNum = p.Faction match { case PlanetSideEmpire.TR => 0x10000 case PlanetSideEmpire.NC => 0x20000 @@ -291,149 +273,12 @@ object HackCaptureActor { entry1: HackCaptureActor.HackEntry, entry2: HackCaptureActor.HackEntry ): HackCaptureActor.HackEntry = { - val entry1TimeLeft = entry1.duration.toNanos - (now - entry1.hack_timestamp) - val entry2TimeLeft = entry2.duration.toNanos - (now - entry2.hack_timestamp) + val entry1TimeLeft = entry1.duration.toMillis - (now - entry1.hack_timestamp) + val entry2TimeLeft = entry2.duration.toMillis - (now - entry2.hack_timestamp) if (entry1TimeLeft < entry2TimeLeft) { entry1 } else { entry2 } } - - import akka.pattern.ask - import akka.util.Timeout - import scala.concurrent.duration._ - import scala.concurrent.ExecutionContext.Implicits.global - - private implicit val timeout: Timeout = Timeout(5.seconds) - - private def RewardFacilityCaptureParticipants( - building: Building, - terminal: CaptureTerminal, - hacker: PlayerSource, - time: Long, - isResecured: Boolean - ): Unit = { - val faction: PlanetSideEmpire.Value = terminal.Faction - val (contributionVictor, contributionAgainst) = building.PlayerContribution.keys.partition { _.Faction == faction } - val contributionVictorSize = contributionVictor.size - val flagCarrier = if (!isResecured) { - building.GetFlagSocket.flatMap(_.previousFlag).flatMap(_.Carrier) - } else { - None - } - val request = ask(building.Zone.Activity, ZoneHotSpotProjector.ExposeHeatForRegion(building.Position, building.Definition.SOIRadius.toFloat)) - request.onComplete { - case Success(ZoneHotSpotProjector.ExposedHeat(_, _, activity)) => - val (heatVictor, heatAgainst) = { - val reports = activity.map { _.Activity } - val allHeat: List[Long] = reports.map { a => a.values.foldLeft(0L)(_ + _.Heat) } - val _rewardedHeat: List[Long] = reports.flatMap { rep => rep.get(faction).map { _.Heat.toLong } } - val _enemyHeat = allHeat.indices.map { index => - val allHeatValue = allHeat(index) - val rewardedHeatValue = _rewardedHeat(index) - allHeatValue - rewardedHeatValue - } - (_rewardedHeat, _enemyHeat.toList) - } - val heatVictorSum: Long = heatVictor.sum[Long] - val heatAgainstSum: Long = heatAgainst.sum[Long] - if (contributionVictorSize > 0) { - val contributionRate = if (heatVictorSum * heatAgainstSum != 0) { - math.log(heatVictorSum * contributionVictorSize / heatAgainstSum.toFloat).toFloat - } else { - contributionAgainst.size / contributionVictorSize.toFloat - } - RewardFacilityCaptureParticipants(building, terminal, faction, hacker, building.PlayersInSOI, flagCarrier, isResecured, time, contributionRate) - } - case _ => - RewardFacilityCaptureParticipants(building, terminal, faction, hacker, building.PlayersInSOI, flagCarrier, isResecured, time, victorContributionRate = 1.0f) - } - request.recover { - _ => RewardFacilityCaptureParticipants(building, terminal, faction, hacker, building.PlayersInSOI, flagCarrier, isResecured, time, victorContributionRate = 1.0f) - } - } - - private def RewardFacilityCaptureParticipants( - building: Building, - terminal: CaptureTerminal, - faction: PlanetSideEmpire.Value, - hacker: PlayerSource, - targets: List[Player], - flagCarrier: Option[Player], - isResecured: Boolean, - hackTime: Long, - victorContributionRate: Float - ): Unit = { - val contribution = building.PlayerContribution - val (contributionVictor, contributionAgainst) = contribution.keys.partition { _.Faction == faction } - val contributionVictorSize = contributionVictor.size - val contributionAgainstSize = contributionAgainst.size - val (contributionByTime, contributionByTimePartitioned) = { - val curr = System.currentTimeMillis() - val interval = 300000 - val range: Seq[Long] = { - val htime = hackTime.toInt - ( - if (htime < 60000) { - Seq(htime, interval + htime, 2 * interval + htime) - } else if (htime <= interval) { - Seq(60000, htime, interval + htime, 2 * interval + htime) - } else { - (60000 +: (interval to htime by interval)) ++ Seq(interval + htime, 2 * interval + htime) - } - ).map { _.toLong } - } - val playerMap = Array.fill[mutable.ListBuffer[Player]](range.size)(mutable.ListBuffer.empty) - contribution.foreach { case (p, t) => - playerMap(range.lastIndexWhere(time => curr - t <= time)).addOne(p) - } - (playerMap, playerMap.map { _.partition(_.Faction == faction) }) - } - val contributionByTimeSize = contributionByTime.length - - val base: Long = 50L - val overallPopulationBonus = { - contributionByTime.map { _.size }.sum * contributionByTimeSize + - contributionByTime.zipWithIndex.map { case (lst, index) => - ((contributionByTimeSize - index) * lst.size * - { - val lists = contributionByTimePartitioned(index) - lists._2.size / math.max(lists._1.size, 1).toFloat - }).toLong - }.sum - } - val competitionBonus: Long = if (contributionAgainstSize * 1.5f < contributionVictorSize.toFloat) { - //steamroll by the victor - 25L * (contributionVictorSize - contributionAgainstSize) - } else if (contributionVictorSize * 1.5f <= contributionAgainstSize.toFloat) { - //victory against overwhelming odds - 500L + 50L * contribution.keys.size - } else { - //still a battle - 10L * math.min(contributionAgainstSize, contributionVictorSize) - } - val timeMultiplier: Float = { - val buildingHackTimeMilli = terminal.Definition.FacilityHackTime.toMillis.toFloat - 1f + (if (isResecured) { - (buildingHackTimeMilli - hackTime) / buildingHackTimeMilli - } else { - 0f - }) - } - val finalCep: Long = ((base + overallPopulationBonus + competitionBonus) * timeMultiplier * Config.app.game.cepRate).toLong - //reward participant(s) -// targets -// .filter { player => -// player.Faction == faction && !player.Name.equals(hacker.Name) -// } -// .foreach { player => -// events ! AvatarServiceMessage(player.Name, AvatarAction.AwardCep(0, finalCep)) -// } -// events ! AvatarServiceMessage(hacker.Name, AvatarAction.AwardCep(hacker.CharId, finalCep)) -// flagCarrier match { -// case Some(player) => events ! AvatarServiceMessage(player.Name, AvatarAction.AwardCep(player.CharId, finalCep / 2)) -// case None => ; -// } - } } diff --git a/src/main/scala/net/psforever/services/local/support/HackClearActor.scala b/src/main/scala/net/psforever/services/local/support/HackClearActor.scala index 2e3599fec..c6f8e8bb4 100644 --- a/src/main/scala/net/psforever/services/local/support/HackClearActor.scala +++ b/src/main/scala/net/psforever/services/local/support/HackClearActor.scala @@ -29,15 +29,15 @@ class HackClearActor() extends Actor { def receive: Receive = { case HackClearActor.ObjectIsHacked(target, zone, unk1, unk2, duration, time) => - val durationNanos = TimeUnit.NANOSECONDS.convert(duration, TimeUnit.SECONDS) - hackedObjects = hackedObjects :+ HackClearActor.HackEntry(target, zone, unk1, unk2, time, durationNanos) + val durationMillis = TimeUnit.MILLISECONDS.convert(duration, TimeUnit.SECONDS) + hackedObjects = hackedObjects :+ HackClearActor.HackEntry(target, zone, unk1, unk2, time, durationMillis) // Restart the timer, in case this is the first object in the hacked objects list RestartTimer() case HackClearActor.TryClearHacks() => clearTrigger.cancel() - val now: Long = System.nanoTime + val now: Long = System.currentTimeMillis() //TODO we can just walk across the list of doors and extract only the first few entries val (unhackObjects, stillHackedObjects) = PartitionEntries(hackedObjects, now) hackedObjects = stillHackedObjects @@ -75,12 +75,12 @@ class HackClearActor() extends Actor { private def RestartTimer(): Unit = { if (hackedObjects.nonEmpty) { - val now = System.nanoTime() + val now = System.currentTimeMillis() val (_/*unhackObjects*/, stillHackedObjects) = PartitionEntries(hackedObjects, now) stillHackedObjects.headOption match { case Some(hackEntry) => - val short_timeout: FiniteDuration = math.max(1, hackEntry.duration - (now - hackEntry.time)) nanoseconds + val short_timeout: FiniteDuration = math.max(1, hackEntry.duration - (now - hackEntry.time)).milliseconds log.debug( s"HackClearActor: still items left in hacked objects list. Checking again in ${short_timeout.toSeconds} seconds" @@ -102,7 +102,7 @@ class HackClearActor() extends Actor { * and newer entries are always added to the end of the main `List`, * processing in order is always correct. * @param list the `List` of entries to divide - * @param now the time right now (in nanoseconds) + * @param now the time right now (in milliseconds) * @see `List.partition` * @return a `Tuple` of two `Lists`, whose qualifications are explained above */ @@ -119,7 +119,7 @@ class HackClearActor() extends Actor { * a `List` of elements that have exceeded the time limit, * and a `List` of elements that still satisfy the time limit. * @param iter the `Iterator` of entries to divide - * @param now the time right now (in nanoseconds) + * @param now the time right now (in milliseconds) * @param index a persistent record of the index where list division should occur; * defaults to 0 * @return the index where division will occur @@ -158,7 +158,7 @@ object HackClearActor { unk1: Long, unk2: Long, duration: Int, - time: Long = System.nanoTime() + time: Long = System.currentTimeMillis() ) /** @@ -185,7 +185,7 @@ object HackClearActor { * @param target the server object * @param zone the zone in which the object resides * @param time when the object was hacked - * @param duration The hack duration in nanoseconds + * @param duration The hack duration in milliseconds * @see `ObjectIsHacked` */ private final case class HackEntry( diff --git a/src/main/scala/net/psforever/services/support/SupportActor.scala b/src/main/scala/net/psforever/services/support/SupportActor.scala index dd3076e4f..bb52e1f0b 100644 --- a/src/main/scala/net/psforever/services/support/SupportActor.scala +++ b/src/main/scala/net/psforever/services/support/SupportActor.scala @@ -96,7 +96,7 @@ abstract class SupportActor[A <: SupportActor.Entry] extends Actor { object SupportActor { class Entry(val obj: PlanetSideGameObject, val zone: Zone, val duration: Long) { - val time: Long = System.nanoTime + val time: Long = System.currentTimeMillis() } /** diff --git a/src/main/scala/net/psforever/services/vehicle/support/TurretUpgrader.scala b/src/main/scala/net/psforever/services/vehicle/support/TurretUpgrader.scala index fa93a1292..eb064d72b 100644 --- a/src/main/scala/net/psforever/services/vehicle/support/TurretUpgrader.scala +++ b/src/main/scala/net/psforever/services/vehicle/support/TurretUpgrader.scala @@ -50,8 +50,8 @@ class TurretUpgrader extends SupportActor[TurretUpgrader.Entry] { entryManagementBehaviors .orElse { case TurretUpgrader.AddTask(turret, zone, upgrade, duration) => - val lengthOfTime = duration.getOrElse(TurretUpgrader.StandardUpgradeLifetime).toNanos - if (lengthOfTime > (1 second).toNanos) { //don't even bother if it's too short; it'll revert near instantly + val lengthOfTime = duration.getOrElse(TurretUpgrader.StandardUpgradeLifetime).toMillis + if (lengthOfTime > (1 second).toMillis) { //don't even bother if it's too short; it'll revert near instantly val entry = CreateEntry(turret, zone, TurretUpgrade.None, lengthOfTime) UpgradeTurretAmmo(CreateEntry(turret, zone, upgrade, lengthOfTime)) if (list.isEmpty) { @@ -73,7 +73,7 @@ class TurretUpgrader extends SupportActor[TurretUpgrader.Entry] { case TurretUpgrader.Downgrade() => task.cancel() - val now: Long = System.nanoTime + val now: Long = System.currentTimeMillis() val (in, out) = list.partition(entry => { now - entry.time >= entry.duration @@ -85,10 +85,10 @@ class TurretUpgrader extends SupportActor[TurretUpgrader.Entry] { case _ => ; } - def RetimeFirstTask(now: Long = System.nanoTime): Unit = { + def RetimeFirstTask(now: Long = System.currentTimeMillis()): Unit = { task.cancel() if (list.nonEmpty) { - val short_timeout: FiniteDuration = math.max(1, list.head.duration - (now - list.head.time)) nanoseconds + val short_timeout: FiniteDuration = math.max(1, list.head.duration - (now - list.head.time)).milliseconds import scala.concurrent.ExecutionContext.Implicits.global task = context.system.scheduler.scheduleOnce(short_timeout, self, TurretUpgrader.Downgrade()) } @@ -249,7 +249,7 @@ object TurretUpgrader extends SupportActorCaseConversions { * @param _obj the target * @param _zone the zone in which this target is registered * @param upgrade the next upgrade state for this turret - * @param _duration how much longer the target will exist in its current state (in nanoseconds) + * @param _duration how much longer the target will exist in its current state (in milliseconds) */ case class Entry(_obj: PlanetSideGameObject, _zone: Zone, upgrade: TurretUpgrade.Value, _duration: Long) extends SupportActor.Entry(_obj, _zone, _duration) diff --git a/src/main/scala/net/psforever/types/Statistics.scala b/src/main/scala/net/psforever/types/Statistics.scala index 27272ea67..599b9ff31 100644 --- a/src/main/scala/net/psforever/types/Statistics.scala +++ b/src/main/scala/net/psforever/types/Statistics.scala @@ -2,6 +2,7 @@ package net.psforever.types import enumeratum.values.{IntEnum, IntEnumEntry} +import net.psforever.objects.definition.ExoSuitDefinition import net.psforever.types.StatisticalElement.{AMS, ANT, AgileExoSuit, ApcNc, ApcTr, ApcVs, Aphelion, AphelionFlight, AphelionGunner, Battlewagon, Colossus, ColossusFlight, ColossusGunner, Dropship, Flail, Fury, GalaxyGunship, InfiltrationExoSuit, Liberator, Lightgunship, Lightning, Lodestar, Magrider, MechanizedAssaultExoSuit, MediumTransport, Mosquito, Peregrine, PeregrineFlight, PeregrineGunner, PhalanxTurret, PortableMannedTurretNc, PortableMannedTurretTr, PortableMannedTurretVs, Prowler, QuadAssault, QuadStealth, Raider, ReinforcedExoSuit, Router, Skyguard, StandardExoSuit, Sunderer, Switchblade, ThreeManHeavyBuggy, Thunderer, TwoManAssaultBuggy, TwoManHeavyBuggy, TwoManHoverBuggy, VanSentryTurret, Vanguard, Vulture, Wasp} sealed abstract class StatisticalCategory(val value: Int) extends IntEnumEntry @@ -64,16 +65,16 @@ object StatisticalCategory extends IntEnum[StatisticalCategory] { Seq(Phantasm, ImplantTerminalMech, Droppod, SpitfireAA, SpitfireCloaked, SpitfireTurret, TankTraps) ++ driverOnlyVehicles ++ gunnerVehicles ++ mannedTurretElements ++ exosuitElements, Seq( - // Chaingun12mm, Chaingun15mm, Cannon20mm, Deliverer20mm, DropshipL20mm, Cannon75mm, Lightning75mm, AdvancedMissileLauncherT, AMS, AnniversaryGun, AnniversaryGunA, AnniversaryGunB, ANT, Sunderer, ApcBallGunL, ApcBallGunR, ApcTr, ApcNc, ApcVs, ApcWeaponSystemA, ApcWeaponSystemB, ApcWeaponSystemC, ApcWeaponSystemCNc, ApcWeaponSystemCTr, ApcWeaponSystemCVs, ApcWeaponSystemD, ApcWeaponSystemDNc, ApcWeaponSystemDTr, ApcWeaponSystemDVs, Aphelion, AphelionArmorSiphon, AphelionFlight, AphelionGunner, AphelionImmolationCannon, AphelionLaser, AphelionNtuSiphon, AphelionPlasmaCloud, AphelionPlasmaRocketPod, - // AphelionPpa, AphelionStarfire, AuroraWeaponSystemA, AuroraWeaponSystemB, Battlewagon, BattlewagonWeaponSystemA, BattlewagonWeaponSystemB, BattlewagonWeaponSystemC, BattlewagonWeaponSystemD, Infantry, Raider, Beamer, BoltDriver, Boomer, Chainblade, ChaingunP, Colossus, ColossusArmorSiphon, ColossusBurster, ColossusChaingun, ColossusClusterBombPod, ColossusDual100mmCannons, ColossusFlight, - // ColossusGunner, ColossusNtuSiphon, ColossusTankCannon, Cycler, CyclerV2, CyclerV3, CyclerV4, Dropship, DropshipRearTurret, Dynomite, EnergyGunNc, EnergyGunTr, EnergyGunVs, Flail, FlailWeapon, Flamethrower, - // Flechette, FluxCannonThresher, Fluxpod, Forceblade, FragGrenade, Fury, FragmentationGrenade, FuryWeaponSystemA, GalaxyGunship, GalaxyGunshipCannon, GalaxyGunshipGun, GalaxyGunshipTailgun, Gauss, GaussCannon, GrenadeLauncherMarauder, HeMine, HeavyRailBeamMagrider, HeavySniper, Hellfire, - // Hunterseeker, Ilc9, Isp, JammerGrenade, Katana, Knife, Lancer, Lasher, Liberator, Liberator25mmCannon, LiberatorBombBay, LiberatorWeaponSystem, Lightgunship, LightgunshipWeapon20mm, LightgunshipWeaponRocket, LightgunshipWeaponSystem, Lightning, LightningWeaponSystem, Lodestar, Maelstrom, Magcutter, Magrider, PhalanxTurret, - // MedicalApplicator, MediumTransport, MediumTransportWeaponSystemA, MediumTransportWeaponSystemB, MineSweeper, MiniChaingun, Mosquito, NchevFalcon, NchevScattercannon, NchevSparrow, Oicw, - // OrbitalStrikeBig, OrbitalStrikeSmall, ParticleBeamMagrider, PelletGun, Peregrine, PeregrineArmorSiphon, PeregrineDualMachineGun, PeregrineDualRocketPods, PeregrineFlight, PeregrineGunner, PeregrineMechhammer, PeregrineNtuSiphon, PeregrineParticleCannon, PeregrineSparrow, PhalanxAvcombo, PhalanxFlakcombo, PhalanxSglHevgatcan, Phantasm, Phantasm12mmMachinegun, Phoenix, PlasmaGrenade, Prowler, ProwlerWeaponSystemA, - // ProwlerWeaponSystemB, Pulsar, PulsedParticleAccelerator, Punisher, QuadAssault, QuadAssaultWeaponSystem, QuadStealth, RShotgun, Radiator, Repeater, Rocklet, RotaryChaingunMosquito, Router, RouterTelepadDeployable, Scythe, SixShooter, Skyguard, SkyguardWeaponSystem, - // Spiker, SpitfireAA, SpitfireCloaked, SpitfireTurret, Striker, Suppressor, Switchblade, ThreeManHeavyBuggy, Thumper, Thunderer, ThundererWeaponSystemA, ThundererWeaponSystemB, TrhevBurster, TrhevDualcycler, TrhevPounder, TwoManAssaultBuggy, TwoManHeavyBuggy, - // TwoManHoverBuggy, Vanguard, VanguardWeapon150mm, VanguardWeapon20mm, VanguardWeaponSystem, VanuModule, VanuSentryTurretWeapon, VanuModuleBeam, VshevComet, VshevQuasar, VshevStarfire, Vulture, VultureBombBay, VultureNoseWeaponSystem, VultureTailCannon, Wasp, WaspWeaponSystem, Winchester + Chaingun12mm, Chaingun15mm, Cannon20mm, Deliverer20mm, DropshipL20mm, Cannon75mm, Lightning75mm, AdvancedMissileLauncherT, AMS, AnniversaryGun, AnniversaryGunA, AnniversaryGunB, ANT, Sunderer, ApcBallGunL, ApcBallGunR, ApcTr, ApcNc, ApcVs, ApcWeaponSystemA, ApcWeaponSystemB, ApcWeaponSystemC, ApcWeaponSystemCNc, ApcWeaponSystemCTr, ApcWeaponSystemCVs, ApcWeaponSystemD, ApcWeaponSystemDNc, ApcWeaponSystemDTr, ApcWeaponSystemDVs, Aphelion, AphelionArmorSiphon, AphelionFlight, AphelionGunner, AphelionImmolationCannon, AphelionLaser, AphelionNtuSiphon, AphelionPlasmaCloud, AphelionPlasmaRocketPod, + AphelionPpa, AphelionStarfire, AuroraWeaponSystemA, AuroraWeaponSystemB, Battlewagon, BattlewagonWeaponSystemA, BattlewagonWeaponSystemB, BattlewagonWeaponSystemC, BattlewagonWeaponSystemD, Infantry, Raider, Beamer, BoltDriver, Boomer, Chainblade, ChaingunP, Colossus, ColossusArmorSiphon, ColossusBurster, ColossusChaingun, ColossusClusterBombPod, ColossusDual100mmCannons, ColossusFlight, + ColossusGunner, ColossusNtuSiphon, ColossusTankCannon, Cycler, CyclerV2, CyclerV3, CyclerV4, Dropship, DropshipRearTurret, Dynomite, EnergyGunNc, EnergyGunTr, EnergyGunVs, Flail, FlailWeapon, Flamethrower, + Flechette, FluxCannonThresher, Fluxpod, Forceblade, FragGrenade, Fury, FragmentationGrenade, FuryWeaponSystemA, GalaxyGunship, GalaxyGunshipCannon, GalaxyGunshipGun, GalaxyGunshipTailgun, Gauss, GaussCannon, GrenadeLauncherMarauder, HeMine, HeavyRailBeamMagrider, HeavySniper, Hellfire, + Hunterseeker, Ilc9, Isp, JammerGrenade, Katana, Knife, Lancer, Lasher, Liberator, Liberator25mmCannon, LiberatorBombBay, LiberatorWeaponSystem, Lightgunship, LightgunshipWeapon20mm, LightgunshipWeaponRocket, LightgunshipWeaponSystem, Lightning, LightningWeaponSystem, Lodestar, Maelstrom, Magcutter, Magrider, PhalanxTurret, + MedicalApplicator, MediumTransport, MediumTransportWeaponSystemA, MediumTransportWeaponSystemB, MineSweeper, MiniChaingun, Mosquito, NchevFalcon, NchevScattercannon, NchevSparrow, Oicw, + OrbitalStrikeBig, OrbitalStrikeSmall, ParticleBeamMagrider, PelletGun, Peregrine, PeregrineArmorSiphon, PeregrineDualMachineGun, PeregrineDualRocketPods, PeregrineFlight, PeregrineGunner, PeregrineMechhammer, PeregrineNtuSiphon, PeregrineParticleCannon, PeregrineSparrow, PhalanxAvcombo, PhalanxFlakcombo, PhalanxSglHevgatcan, Phantasm, Phantasm12mmMachinegun, Phoenix, PlasmaGrenade, Prowler, ProwlerWeaponSystemA, + ProwlerWeaponSystemB, Pulsar, PulsedParticleAccelerator, Punisher, QuadAssault, QuadAssaultWeaponSystem, QuadStealth, RShotgun, Radiator, Repeater, Rocklet, RotaryChaingunMosquito, Router, RouterTelepadDeployable, Scythe, SixShooter, Skyguard, SkyguardWeaponSystem, + Spiker, SpitfireAA, SpitfireCloaked, SpitfireTurret, Striker, Suppressor, Switchblade, ThreeManHeavyBuggy, Thumper, Thunderer, ThundererWeaponSystemA, ThundererWeaponSystemB, TrhevBurster, TrhevDualcycler, TrhevPounder, TwoManAssaultBuggy, TwoManHeavyBuggy, + TwoManHoverBuggy, Vanguard, VanguardWeapon150mm, VanguardWeapon20mm, VanguardWeaponSystem, VanuModule, VanuSentryTurretWeapon, VanuModuleBeam, VshevComet, VshevQuasar, VshevStarfire, Vulture, VultureBombBay, VultureNoseWeaponSystem, VultureTailCannon, Wasp, WaspWeaponSystem, Winchester ), Seq(Facilities, Redoubt, Tower, VanuControlPoint, VanuVehicleStation), exosuitElements, @@ -402,4 +403,31 @@ object StatisticalElement extends IntEnum[StatisticalElement] { final case object XmasSnowmanIshundar extends StatisticalElement(value = 1044) final case object XmasSnowmanSearhus extends StatisticalElement(value = 1045) final case object XmasSnowmanSolsar extends StatisticalElement(value = 1046) + + def fromId(id: Int): StatisticalElement = { + values.find(_.value == id).getOrElse(StatisticalElement.Door) + } + + def relatedElement(key: Any): StatisticalElement = { + key match { + case suit: ExoSuitType.Value => + Seq( + StatisticalElement.AgileExoSuit, + StatisticalElement.ReinforcedExoSuit, + StatisticalElement.MechanizedAssaultExoSuit, + StatisticalElement.InfiltrationExoSuit, + StatisticalElement.StandardExoSuit + )(suit.id) + case exodef: ExoSuitDefinition => + Seq( + StatisticalElement.AgileExoSuit, + StatisticalElement.ReinforcedExoSuit, + StatisticalElement.MechanizedAssaultExoSuit, + StatisticalElement.InfiltrationExoSuit, + StatisticalElement.StandardExoSuit + )(exodef.SuitType.id) + case _ => + StatisticalElement.Door //no one cares about doors + } + } } diff --git a/src/main/scala/net/psforever/util/Config.scala b/src/main/scala/net/psforever/util/Config.scala index fa5c742fb..89150b514 100644 --- a/src/main/scala/net/psforever/util/Config.scala +++ b/src/main/scala/net/psforever/util/Config.scala @@ -150,8 +150,6 @@ case class GameConfig( instantAction: InstantActionConfig, amenityAutorepairRate: Float, amenityAutorepairDrainRate: Float, - bepRate: Double, - cepRate: Double, newAvatar: NewAvatar, hart: HartConfig, sharedMaxCooldown: Boolean, @@ -161,7 +159,10 @@ case class GameConfig( cavernRotation: CavernRotationConfig, savedMsg: SavedMessageEvents, playerDraw: PlayerStateDrawSettings, - doorsCanBeOpenedByMedAppFromThisDistance: Float + doorsCanBeOpenedByMedAppFromThisDistance: Float, + experience: Experience, + maxBattleRank: Int, + promotion: PromotionSystem ) case class InstantActionConfig( @@ -237,3 +238,59 @@ case class PlayerStateDrawSettings( assert(ranges.nonEmpty) assert(ranges.size == delays.size) } + +case class Experience( + shortContributionTime: Long, + longContributionTime: Long, + bep: BattleExperiencePoints, + sep: SupportExperiencePoints, + cep: CommandExperiencePoints +) { + assert(shortContributionTime < longContributionTime) +} + +case class BattleExperiencePoints( + base: BattleExperiencePointsBase, + rate: Float +) + +case class BattleExperiencePointsBase( + bopsMultiplier: Long, + asMax: Long, + withKills: Long, + asMounted: Long, + mature: Long +) + +case class SupportExperiencePoints( + rate: Float, + ntuSiloDepositReward: Long, + canNotFindEventDefaultValue: Long, + events: Seq[SupportExperienceEvent] +) + +case class SupportExperienceEvent( + name: String, + base: Long, + shotsMultiplier: Float = 0f, + shotsNatLog: Double = 0f, + shotsLimit: Int = 50, + shotsCutoff: Int = 50, + amountMultiplier: Float = 0f +) + +case class CommandExperiencePoints( + rate: Float, + lluCarrierModifier: Float, + lluSlayerCreditDuration: Duration, + lluSlayerCredit: Long, + maximumPerSquadSize: Seq[Int], + squadSizeLimitOverflow: Int, + squadSizeLimitOverflowMultiplier: Float +) + +case class PromotionSystem( + active: Boolean, + maxBattleRank: Int, + battleExperiencePointsModifier: Float +) diff --git a/src/test/scala/game/AvatarStatisticsMessageTest.scala b/src/test/scala/game/AvatarStatisticsMessageTest.scala index 8bd8589ae..9cec8c123 100644 --- a/src/test/scala/game/AvatarStatisticsMessageTest.scala +++ b/src/test/scala/game/AvatarStatisticsMessageTest.scala @@ -30,7 +30,7 @@ class AvatarStatisticsMessageTest extends Specification { PacketCoding.decodePacket(string_complex).require match { case AvatarStatisticsMessage(stat) => stat match { - case InitStatistic(a, b, c) => + case CampaignStatistic(a, b, c) => a mustEqual StatisticalCategory.Destroyed b mustEqual StatisticalElement.Mosquito c mustEqual List(1, 6, 0, 1, 1, 2, 0, 0) @@ -51,7 +51,7 @@ class AvatarStatisticsMessageTest extends Specification { "encode (complex)" in { val msg = AvatarStatisticsMessage( - InitStatistic(StatisticalCategory.Destroyed, StatisticalElement.Mosquito, List[Long](1, 6, 0, 1, 1, 2, 0, 0)) + CampaignStatistic(StatisticalCategory.Destroyed, StatisticalElement.Mosquito, List[Long](1, 6, 0, 1, 1, 2, 0, 0)) ) val pkt = PacketCoding.encodePacket(msg).require.toByteVector @@ -60,7 +60,7 @@ class AvatarStatisticsMessageTest extends Specification { "encode (failure; complex; wrong number of list entries)" in { val msg = AvatarStatisticsMessage( - InitStatistic(StatisticalCategory.Destroyed, StatisticalElement.Mosquito, List[Long](1, 6, 0, 1)) + CampaignStatistic(StatisticalCategory.Destroyed, StatisticalElement.Mosquito, List[Long](1, 6, 0, 1)) ) PacketCoding.encodePacket(msg).isFailure mustEqual true } diff --git a/src/test/scala/objects/ConverterTest.scala b/src/test/scala/objects/ConverterTest.scala index 3e5aab963..d20476c9d 100644 --- a/src/test/scala/objects/ConverterTest.scala +++ b/src/test/scala/objects/ConverterTest.scala @@ -564,7 +564,7 @@ class ConverterTest extends Specification { obj.Faction = PlanetSideEmpire.TR obj.GUID = PlanetSideGUID(90) obj.Router = PlanetSideGUID(1001) - obj.Owner = PlanetSideGUID(5001) + obj.OwnerGuid = PlanetSideGUID(5001) obj.Health = 1 obj.Definition.Packet.ConstructorData(obj) match { case Success(pkt) => @@ -596,7 +596,7 @@ class ConverterTest extends Specification { obj.Faction = PlanetSideEmpire.TR obj.GUID = PlanetSideGUID(90) obj.Router = PlanetSideGUID(1001) - obj.Owner = PlanetSideGUID(5001) + obj.OwnerGuid = PlanetSideGUID(5001) obj.Health = 0 obj.Definition.Packet.ConstructorData(obj) match { case Success(pkt) => @@ -628,7 +628,7 @@ class ConverterTest extends Specification { obj.Faction = PlanetSideEmpire.TR obj.GUID = PlanetSideGUID(90) //obj.Router = PlanetSideGUID(1001) - obj.Owner = PlanetSideGUID(5001) + obj.OwnerGuid = PlanetSideGUID(5001) obj.Health = 1 obj.Definition.Packet.ConstructorData(obj).isFailure mustEqual true @@ -641,7 +641,7 @@ class ConverterTest extends Specification { obj.Faction = PlanetSideEmpire.TR obj.GUID = PlanetSideGUID(90) obj.Router = PlanetSideGUID(1001) - obj.Owner = PlanetSideGUID(5001) + obj.OwnerGuid = PlanetSideGUID(5001) obj.Health = 1 obj.Definition.Packet.DetailedConstructorData(obj).isFailure mustEqual true } diff --git a/src/test/scala/objects/DeployableTest.scala b/src/test/scala/objects/DeployableTest.scala index 3e918dd08..d8a276367 100644 --- a/src/test/scala/objects/DeployableTest.scala +++ b/src/test/scala/objects/DeployableTest.scala @@ -34,16 +34,17 @@ class DeployableTest extends Specification { "Deployable" should { "know its owner by GUID" in { val obj = new ExplosiveDeployable(GlobalDefinitions.he_mine) - obj.Owner.isEmpty mustEqual true - obj.Owner = PlanetSideGUID(10) - obj.Owner.contains(PlanetSideGUID(10)) mustEqual true + obj.OwnerGuid.isEmpty mustEqual true + obj.OwnerGuid = PlanetSideGUID(10) + obj.OwnerGuid.contains(PlanetSideGUID(10)) mustEqual true } "know its owner by GUID" in { - val obj = new ExplosiveDeployable(GlobalDefinitions.he_mine) - obj.OwnerName.isEmpty mustEqual true - obj.OwnerName = "TestCharacter" - obj.OwnerName.contains("TestCharacter") mustEqual true +// val obj = new ExplosiveDeployable(GlobalDefinitions.he_mine) +// obj.OwnerName.isEmpty mustEqual true +// obj.OwnerName = "TestCharacter" +// obj.OwnerName.contains("TestCharacter") mustEqual true + ko } "know its faction allegiance" in { @@ -339,8 +340,8 @@ class ExplosiveDeployableJammerTest extends ActorTest { guid.register(player2, 4) guid.register(weapon, 5) j_mine.Zone = zone - j_mine.Owner = player2 - j_mine.OwnerName = player2.Name + j_mine.OwnerGuid = player2 + //j_mine.OwnerName = player2.Name j_mine.Faction = PlanetSideEmpire.NC j_mine.Actor = system.actorOf(Props(classOf[MineDeployableControl], j_mine), "j-mine-control") @@ -422,8 +423,8 @@ class ExplosiveDeployableJammerExplodeTest extends ActorTest { guid.register(player2, 4) guid.register(weapon, 5) h_mine.Zone = zone - h_mine.Owner = player2 - h_mine.OwnerName = player2.Name + h_mine.OwnerGuid = player2 + //h_mine.OwnerName = player2.Name h_mine.Faction = PlanetSideEmpire.NC h_mine.Actor = system.actorOf(Props(classOf[MineDeployableControl], h_mine), "h-mine-control") zone.blockMap.addTo(player1) @@ -528,8 +529,8 @@ class ExplosiveDeployableDestructionTest extends ActorTest { guid.register(player2, 4) guid.register(weapon, 5) h_mine.Zone = zone - h_mine.Owner = player2 - h_mine.OwnerName = player2.Name + h_mine.OwnerGuid = player2 + //h_mine.OwnerName = player2.Name h_mine.Faction = PlanetSideEmpire.NC h_mine.Actor = system.actorOf(Props(classOf[MineDeployableControl], h_mine), "h-mine-control") diff --git a/src/test/scala/objects/PlayerControlTest.scala b/src/test/scala/objects/PlayerControlTest.scala index 9266d0c79..df5833690 100644 --- a/src/test/scala/objects/PlayerControlTest.scala +++ b/src/test/scala/objects/PlayerControlTest.scala @@ -624,7 +624,7 @@ class PlayerControlDeathStandingTest extends ActorTest { // override def AvatarEvents = avatarProbe.ref // override def Activity = activityProbe.ref // } -// zone.actor = system.spawn(ZoneActor(zone), s"test-zone-${System.nanoTime()}") +// zone.actor = system.spawn(ZoneActor(zone), s"test-zone-${System.currentTimeMillis()}") // // player1.Zone = zone // player1.Spawn() diff --git a/src/test/scala/objects/VehicleControlTest.scala b/src/test/scala/objects/VehicleControlTest.scala index e65bb2d11..5b2bb09b4 100644 --- a/src/test/scala/objects/VehicleControlTest.scala +++ b/src/test/scala/objects/VehicleControlTest.scala @@ -310,7 +310,7 @@ class VehicleControlMountingBlockedExosuitTest extends ActorTest { // Reset to allow further driver mount mounting tests vehicle.Actor.tell(Mountable.TryDismount(player1, 0), probe.ref) probe.receiveOne(500 milliseconds) //discard - vehicle.Owner = None //ensure + vehicle.OwnerGuid = None //ensure vehicle.OwnerName = None //ensure vehicle.Actor.tell(Mountable.TryMount(player3, 1), probe.ref) // Agile in driver mount allowing all except MAX VehicleControlTest.checkCanMount(probe, "Agile in driver mount allowing all except MAX") @@ -369,7 +369,7 @@ class VehicleControlMountingDriverSeatTest extends ActorTest { "allow players to sit in the driver mount, even if it is locked, if the vehicle is unowned" in { assert(vehicle.PermissionGroup(0).contains(VehicleLockState.Locked)) //driver group -> locked assert(vehicle.Seats(0).occupant.isEmpty) - assert(vehicle.Owner.isEmpty) + assert(vehicle.OwnerGuid.isEmpty) vehicle.Actor.tell(Mountable.TryMount(player1, 1), probe.ref) VehicleControlTest.checkCanMount(probe, "") assert(vehicle.Seats(0).occupant.nonEmpty) @@ -397,7 +397,7 @@ class VehicleControlMountingOwnedLockedDriverSeatTest extends ActorTest { "block players that are not the current owner from sitting in the driver mount (locked)" in { assert(vehicle.PermissionGroup(0).contains(VehicleLockState.Locked)) //driver group -> locked assert(vehicle.Seats(0).occupant.isEmpty) - vehicle.Owner = player1.GUID + vehicle.OwnerGuid = player1.GUID vehicle.Actor.tell(Mountable.TryMount(player1, 1), probe.ref) VehicleControlTest.checkCanMount(probe, "") @@ -434,7 +434,7 @@ class VehicleControlMountingOwnedUnlockedDriverSeatTest extends ActorTest { vehicle.PermissionGroup(0, 3) //passenger group -> empire assert(vehicle.PermissionGroup(0).contains(VehicleLockState.Empire)) //driver group -> empire assert(vehicle.Seats(0).occupant.isEmpty) - vehicle.Owner = player1.GUID //owner set + vehicle.OwnerGuid = player1.GUID //owner set vehicle.Actor.tell(Mountable.TryMount(player1, 1), probe.ref) VehicleControlTest.checkCanMount(probe, "") diff --git a/src/test/scala/objects/VehicleTest.scala b/src/test/scala/objects/VehicleTest.scala index e850ce911..1d0d7ea1e 100644 --- a/src/test/scala/objects/VehicleTest.scala +++ b/src/test/scala/objects/VehicleTest.scala @@ -121,7 +121,7 @@ class VehicleTest extends Specification { "construct (detailed)" in { val fury_vehicle = Vehicle(GlobalDefinitions.fury) - fury_vehicle.Owner.isEmpty mustEqual true + fury_vehicle.OwnerGuid.isEmpty mustEqual true fury_vehicle.Seats.size mustEqual 1 fury_vehicle.Seats(0).definition.restriction mustEqual NoMax fury_vehicle.Seats(0).isOccupied mustEqual false @@ -148,30 +148,30 @@ class VehicleTest extends Specification { "can be owned by a player" in { val fury_vehicle = Vehicle(GlobalDefinitions.fury) - fury_vehicle.Owner.isDefined mustEqual false + fury_vehicle.OwnerGuid.isDefined mustEqual false val player1 = Player(avatar1) player1.GUID = PlanetSideGUID(1) - fury_vehicle.Owner = player1 - fury_vehicle.Owner.isDefined mustEqual true - fury_vehicle.Owner.contains(PlanetSideGUID(1)) mustEqual true + fury_vehicle.OwnerGuid = player1 + fury_vehicle.OwnerGuid.isDefined mustEqual true + fury_vehicle.OwnerGuid.contains(PlanetSideGUID(1)) mustEqual true } "ownership depends on who last was granted it" in { val fury_vehicle = Vehicle(GlobalDefinitions.fury) - fury_vehicle.Owner.isDefined mustEqual false + fury_vehicle.OwnerGuid.isDefined mustEqual false val player1 = Player(avatar1) player1.GUID = PlanetSideGUID(1) - fury_vehicle.Owner = player1 - fury_vehicle.Owner.isDefined mustEqual true - fury_vehicle.Owner.contains(PlanetSideGUID(1)) mustEqual true + fury_vehicle.OwnerGuid = player1 + fury_vehicle.OwnerGuid.isDefined mustEqual true + fury_vehicle.OwnerGuid.contains(PlanetSideGUID(1)) mustEqual true val player2 = Player(avatar2) player2.GUID = PlanetSideGUID(2) - fury_vehicle.Owner = player2 - fury_vehicle.Owner.isDefined mustEqual true - fury_vehicle.Owner.contains(PlanetSideGUID(2)) mustEqual true + fury_vehicle.OwnerGuid = player2 + fury_vehicle.OwnerGuid.isDefined mustEqual true + fury_vehicle.OwnerGuid.contains(PlanetSideGUID(2)) mustEqual true } "can use mount point to get mount number" in { diff --git a/src/test/scala/objects/VitalityTest.scala b/src/test/scala/objects/VitalityTest.scala index 782cdac00..a7f740f74 100644 --- a/src/test/scala/objects/VitalityTest.scala +++ b/src/test/scala/objects/VitalityTest.scala @@ -40,10 +40,10 @@ class VitalityTest extends Specification { player.LogActivity(result) //DamageResult, straight-up player.LogActivity(DamageFromProjectile(result)) player.LogActivity(HealFromKit(GlobalDefinitions.medkit, 10)) - player.LogActivity(HealFromTerm(term, 10)) + player.LogActivity(HealFromTerminal(term, 10)) player.LogActivity(HealFromImplant(ImplantType.AdvancedRegen, 10)) player.LogActivity(RepairFromExoSuitChange(ExoSuitType.Standard, 10)) - player.LogActivity(RepairFromTerm(term, 10)) + player.LogActivity(RepairFromTerminal(term, 10)) player.LogActivity(ShieldCharge(10, Some(vSource))) player.LogActivity(PlayerSuicide(PlayerSource(player))) ok @@ -55,10 +55,10 @@ class VitalityTest extends Specification { val term = AmenitySource(new Terminal(GlobalDefinitions.order_terminal) { GUID = PlanetSideGUID(1) }) player.LogActivity(HealFromKit(GlobalDefinitions.medkit, 10)) - player.LogActivity(HealFromTerm(term, 10)) + player.LogActivity(HealFromTerminal(term, 10)) player.LogActivity(HealFromImplant(ImplantType.AdvancedRegen, 10)) player.LogActivity(RepairFromExoSuitChange(ExoSuitType.Standard, 10)) - player.LogActivity(RepairFromTerm(term, 10)) + player.LogActivity(RepairFromTerminal(term, 10)) player.LogActivity(ShieldCharge(10, Some(vSource))) player.LogActivity(PlayerSuicide(PlayerSource(player))) player.History.size mustEqual 7 @@ -67,10 +67,10 @@ class VitalityTest extends Specification { player.History.size mustEqual 0 list.head.isInstanceOf[PlayerSuicide] mustEqual true list(1).isInstanceOf[ShieldCharge] mustEqual true - list(2).isInstanceOf[RepairFromTerm] mustEqual true + list(2).isInstanceOf[RepairFromTerminal] mustEqual true list(3).isInstanceOf[RepairFromExoSuitChange] mustEqual true list(4).isInstanceOf[HealFromImplant] mustEqual true - list(5).isInstanceOf[HealFromTerm] mustEqual true + list(5).isInstanceOf[HealFromTerminal] mustEqual true list(6).isInstanceOf[HealFromKit] mustEqual true } @@ -92,10 +92,10 @@ class VitalityTest extends Specification { player.LogActivity(DamageFromProjectile(result)) player.LogActivity(HealFromKit(GlobalDefinitions.medkit, 10)) - player.LogActivity(HealFromTerm(term, 10)) + player.LogActivity(HealFromTerminal(term, 10)) player.LogActivity(HealFromImplant(ImplantType.AdvancedRegen, 10)) player.LogActivity(RepairFromExoSuitChange(ExoSuitType.Standard, 10)) - player.LogActivity(RepairFromTerm(term, 10)) + player.LogActivity(RepairFromTerminal(term, 10)) player.LogActivity(ShieldCharge(10, Some(vSource))) player.LogActivity(PlayerSuicide(PlayerSource(player)))