mirror of
https://github.com/psforever/PSF-LoginServer.git
synced 2026-07-16 08:55:18 +00:00
all rewarded support events are accounted for
This commit is contained in:
parent
d6a37e9421
commit
8bdb215d58
19 changed files with 1393 additions and 913 deletions
|
|
@ -363,7 +363,7 @@ DECLARE weaponId Int;
|
|||
BEGIN
|
||||
killerId := NEW.killer_id;
|
||||
weaponId := NEW.weapon_id;
|
||||
SELECT proc_sessionnumber_get(killerId, killerSessionId);
|
||||
CALL proc_sessionnumber_get(killerId, killerSessionId);
|
||||
BEGIN
|
||||
UPDATE weaponstatsession
|
||||
SET assists = assists + 1
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ 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.{Assist, Death, EquipmentStat, KDAStat, Kill, SupportActivity}
|
||||
import net.psforever.objects.avatar.scoring.{Assist, Death, EquipmentStat, KDAStat, Kill, Life, SupportActivity}
|
||||
import net.psforever.objects.serverobject.affinity.FactionAffinity
|
||||
import net.psforever.objects.sourcing.VehicleSource
|
||||
import net.psforever.objects.vital.InGameHistory
|
||||
|
|
@ -851,6 +851,14 @@ object AvatarActor {
|
|||
)
|
||||
}
|
||||
|
||||
def updateToolDischargeFor(avatarId: Long, lives: Seq[Life]): Unit = {
|
||||
lives
|
||||
.flatMap { _.equipmentStats }
|
||||
.foreach { stat =>
|
||||
zones.exp.ToDatabase.reportToolDischarge(avatarId, stat)
|
||||
}
|
||||
}
|
||||
|
||||
def toAvatar(avatar: persistence.Avatar): Avatar = {
|
||||
val bep = avatar.bep
|
||||
val convertedCosmetics = if (BattleRank.showCosmetics(bep)) {
|
||||
|
|
@ -1083,6 +1091,7 @@ class AvatarActor(
|
|||
.receiveSignal {
|
||||
case (_, PostStop) =>
|
||||
AvatarActor.avatarNoLongerLoggedIn(account.id)
|
||||
AvatarActor.updateToolDischargeFor(avatar.id.toLong, avatar.scorecard.CurrentLife +: avatar.scorecard.Lives)
|
||||
Behaviors.same
|
||||
}
|
||||
}
|
||||
|
|
@ -1707,14 +1716,7 @@ class AvatarActor(
|
|||
Behaviors.same
|
||||
|
||||
case SupportExperienceDeposit(bep, delayBy) =>
|
||||
setBep(avatar.bep + bep, ExperienceType.Support)
|
||||
supportExperiencePool = supportExperiencePool - bep
|
||||
if (supportExperiencePool > 0) {
|
||||
resetSupportExperienceTimer(bep, delayBy)
|
||||
} else {
|
||||
supportExperienceTimer.cancel()
|
||||
supportExperienceTimer = Default.Cancellable
|
||||
}
|
||||
actuallyAwardSupportExperience(bep, delayBy)
|
||||
Behaviors.same
|
||||
|
||||
case SetBep(bep) =>
|
||||
|
|
@ -3022,10 +3024,23 @@ class AvatarActor(
|
|||
}
|
||||
|
||||
def awardSupportExperience(bep: Long, previousDelay: Long): Unit = {
|
||||
supportExperiencePool = supportExperiencePool + bep
|
||||
avatar.scorecard.rate(bep)
|
||||
if (supportExperienceTimer.isCancelled) {
|
||||
resetSupportExperienceTimer(previousBep = 0, previousDelay = 0)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3037,14 +3052,15 @@ class AvatarActor(
|
|||
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
|
||||
}) match {
|
||||
case Some(mount: PlanetSideGameObject with FactionAffinity with InGameHistory with MountedWeapons) =>
|
||||
player.HistoryAndContributions() ++ InGameHistory.ContributionFrom(mount).toList
|
||||
case _ =>
|
||||
player.HistoryAndContributions()
|
||||
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]
|
||||
|
|
@ -3133,7 +3149,6 @@ class AvatarActor(
|
|||
|
||||
def updateToolDischarge(stats: EquipmentStat): Unit = {
|
||||
avatar.scorecard.rate(stats)
|
||||
zones.exp.ToDatabase.reportToolDischarge(avatar.id.toLong, stats)
|
||||
}
|
||||
|
||||
def createAvatar(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -335,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
|
||||
|
||||
|
|
@ -495,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)
|
||||
|
|
|
|||
|
|
@ -237,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) {
|
||||
|
|
@ -255,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.
|
||||
|
|
@ -264,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.OwnerGuid 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
|
||||
|
|
@ -302,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 _ => ()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -412,6 +400,7 @@ object Vehicles {
|
|||
*
|
||||
* @param vehicle the vehicle
|
||||
*/
|
||||
//noinspection ScalaUnusedSymbol
|
||||
def BeforeUnloadVehicle(vehicle: Vehicle, zone: Zone): Unit = {
|
||||
vehicle.Definition match {
|
||||
case GlobalDefinitions.ams =>
|
||||
|
|
@ -420,7 +409,7 @@ object Vehicles {
|
|||
vehicle.Actor ! Deployment.TryUndeploy(DriveState.Undeploying)
|
||||
case GlobalDefinitions.router =>
|
||||
vehicle.Actor ! Deployment.TryUndeploy(DriveState.Undeploying)
|
||||
case _ => ;
|
||||
case _ => ()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,10 @@ object PlayerSource {
|
|||
}
|
||||
|
||||
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,
|
||||
|
|
@ -81,7 +85,7 @@ object PlayerSource {
|
|||
GlobalDefinitions.Standard,
|
||||
bep = 0L,
|
||||
progress = tokenLife,
|
||||
UniquePlayer(0L, name, CharacterSex.Male, faction)
|
||||
unique
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -137,6 +141,19 @@ object PlayerSource {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a copy of a normal player source entity
|
||||
* but the `seatedIn` field is overrode to point at the specified vehicle and seat number.<br>
|
||||
* 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”
|
||||
|
|
@ -146,5 +163,8 @@ object PlayerSource {
|
|||
*/
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,15 +17,15 @@ final case class VehicleSource(
|
|||
Orientation: Vector3,
|
||||
Velocity: Option[Vector3],
|
||||
deployed: DriveState.Value,
|
||||
owner: Option[PlayerSource],
|
||||
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 {
|
||||
|
|
@ -54,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
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,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
|
||||
}
|
||||
|
|
@ -86,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
|
||||
}
|
||||
|
|
@ -112,10 +109,9 @@ trait CarrierBehavior {
|
|||
kicked = false
|
||||
)
|
||||
case _ =>
|
||||
obj.CargoHold(mountPoint) match {
|
||||
case Some(hold) if hold.isOccupied && hold.occupant.get.GUID == cargo_guid =>
|
||||
obj.CargoHold(mountPoint).collect {
|
||||
case hold if hold.isOccupied && hold.occupant.get.GUID == cargo_guid =>
|
||||
CarrierBehavior.CargoDismountAction(obj, hold.occupant.get, hold, BailType.Normal)
|
||||
case _ => ;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
|
@ -132,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
|
||||
}
|
||||
|
|
@ -526,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")
|
||||
}
|
||||
|
|
@ -662,7 +656,17 @@ object CarrierBehavior {
|
|||
carrierGuid: PlanetSideGUID): Unit = {
|
||||
hold.mount(cargo)
|
||||
cargo.MountedIn = carrierGuid
|
||||
cargo.LogActivity(VehicleCargoMountActivity(VehicleSource(carrier), VehicleSource(cargo), carrier.Zone.Number))
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -681,6 +685,18 @@ object CarrierBehavior {
|
|||
): Unit = {
|
||||
cargo.MountedIn = None
|
||||
hold.unmount(cargo, bailType)
|
||||
cargo.LogActivity(VehicleCargoMountActivity(VehicleSource(carrier), VehicleSource(cargo), carrier.Zone.Number))
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ 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, SourceUniqueness, 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}
|
||||
|
|
@ -20,7 +20,21 @@ import scala.collection.mutable
|
|||
* 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 */
|
||||
|
|
@ -66,14 +80,22 @@ sealed trait VehicleCargoMountChange extends VehicleMountChange {
|
|||
final case class VehicleMountActivity(vehicle: VehicleSource, player: PlayerSource, zoneNumber: Int)
|
||||
extends VehiclePassengerMountChange
|
||||
|
||||
final case class VehicleDismountActivity(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)
|
||||
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 {
|
||||
|
|
@ -202,11 +224,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 => ()
|
||||
|
|
@ -264,13 +309,18 @@ trait InGameHistory {
|
|||
if (target eq this) {
|
||||
None
|
||||
} else {
|
||||
target.GetContribution() match {
|
||||
case Some(in) =>
|
||||
val contribution = Contribution(SourceEntry(target).unique, in)
|
||||
contributionInheritance.put(contribution.src, contribution)
|
||||
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 None =>
|
||||
contributionInheritance.remove(SourceEntry(target).unique)
|
||||
case (Some(in), _) =>
|
||||
val contribution = Contribution(uniqueTarget, in)
|
||||
contributionInheritance.put(uniqueTarget, contribution)
|
||||
Some(contribution)
|
||||
case (None, _) =>
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
@ -310,9 +360,9 @@ object InGameHistory {
|
|||
val toUnitSource = unit.collect { case o: PlanetSideGameObject with FactionAffinity => SourceEntry(o) }
|
||||
|
||||
val event: GeneralActivity = if (obj.History.isEmpty) {
|
||||
SpawningActivity(ObjectSource(obj), zoneNumber, toUnitSource)
|
||||
SpawningActivity(SourceEntry(obj), zoneNumber, toUnitSource)
|
||||
} else {
|
||||
ReconstructionActivity(ObjectSource(obj), zoneNumber, toUnitSource)
|
||||
ReconstructionActivity(SourceEntry(obj), zoneNumber, toUnitSource)
|
||||
}
|
||||
if (obj.History.lastOption match {
|
||||
case Some(evt: SpawningActivity) => evt != event
|
||||
|
|
|
|||
|
|
@ -5,27 +5,34 @@ 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(equipment: Int) extends EquipmentUseContextWrapper(value = -1)
|
||||
final case class NoUse() extends NoIntermediateUseContextWrapper(value = -1) {
|
||||
def equipment: Int = 0
|
||||
}
|
||||
|
||||
final case class DamageWith(equipment: Int) extends EquipmentUseContextWrapper(value = 0)
|
||||
final case class DamageWith(equipment: Int) extends NoIntermediateUseContextWrapper(value = 0)
|
||||
|
||||
final case class Destroyed(equipment: Int) extends EquipmentUseContextWrapper(value = 1)
|
||||
final case class ReviveAssist(equipment: Int) extends EquipmentUseContextWrapper(value = 4)
|
||||
final case class AmenityDestroyed(equipment: Int) extends EquipmentUseContextWrapper(value = 10)
|
||||
final case class DriverKilled(equipment: Int) extends EquipmentUseContextWrapper(value = 12)
|
||||
final case class GunnerKilled(equipment: Int) extends EquipmentUseContextWrapper(value = 13)
|
||||
final case class PassengerKilled(equipment: Int) extends EquipmentUseContextWrapper(value = 14)
|
||||
final case class CargoDestroyed(equipment: Int) extends EquipmentUseContextWrapper(value = 15)
|
||||
final case class DriverAssist(equipment: Int) extends EquipmentUseContextWrapper(value = 18)
|
||||
final case class HealKillAssist(equipment: Int) extends EquipmentUseContextWrapper(value = 20)
|
||||
final case class ReviveKillAssist(equipment: Int) extends EquipmentUseContextWrapper(value = 21)
|
||||
final case class RepairKillAssist(equipment: Int, override val intermediate: Int) extends EquipmentUseContextWrapper(value = 22)
|
||||
final case class AmsRespawnKillAssist(equipment: Int) extends EquipmentUseContextWrapper(value = 23)
|
||||
final case class HotDropKillAssist(equipment: Int, override val intermediate: Int) extends EquipmentUseContextWrapper(value = 24)
|
||||
final case class HackKillAssist(equipment: Int) extends EquipmentUseContextWrapper(value = 25)
|
||||
final case class LodestarRearmKillAssist(equipment: Int) extends EquipmentUseContextWrapper(value = 26)
|
||||
final case class AmsResupplyKillAssist(equipment: Int) extends EquipmentUseContextWrapper(value = 27)
|
||||
final case class RouterKillAssist(equipment: Int) extends EquipmentUseContextWrapper(value = 28)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -2,17 +2,110 @@
|
|||
package net.psforever.objects.zones.exp
|
||||
|
||||
import akka.actor.ActorRef
|
||||
import net.psforever.objects.avatar.scoring.{Assist, Death, Kill}
|
||||
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 shortHistory = limitHistoryToThisLife(history.toList)
|
||||
determineKiller(lastDamage, shortHistory) match {
|
||||
case Some((result, killer: PlayerSource)) =>
|
||||
val assists = collectKillAssistsForPlayer(victim, shortHistory, Some(killer))
|
||||
val fullBep = calculateExperience(killer, victim, shortHistory)
|
||||
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 }, shortHistory.last.time - shortHistory.head.time, fullBep)) +: hitSquad
|
||||
|
||||
case _ =>
|
||||
val assists = collectKillAssistsForPlayer(victim, shortHistory, None)
|
||||
val fullBep = Support.baseExperience(victim, shortHistory)
|
||||
val hitSquad = assists.map {
|
||||
case ContributionStatsOutput(p, w, r) => (p, Assist(victim, w, r, (fullBep * r).toLong))
|
||||
}.toSeq
|
||||
(victim, Death(hitSquad.map { _._1 }, shortHistory.last.time - shortHistory.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
|
||||
|
|
@ -30,6 +123,14 @@ object KillAssists {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]
|
||||
|
|
@ -37,16 +138,18 @@ object KillAssists {
|
|||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
.collect { case (dam, Some(adv)) => (dam, adv.attacker) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -55,37 +158,140 @@ object KillAssists {
|
|||
* 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
|
||||
* @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
|
||||
* @see `qualifiedTimeDifferences`
|
||||
* @see `takeWhileLess`
|
||||
*/
|
||||
private[exp] def calculateMenace(player: PlayerSource, mercy: Long = 2500L): Int = {
|
||||
val allKills = player.progress.kills.reverse
|
||||
val restBetweenKills = allKills.take(10) match {
|
||||
case firstKill :: kills if kills.size == 9 =>
|
||||
var xTime = firstKill.time.toDate.getTime
|
||||
kills.map { kill =>
|
||||
val time = kill.time.toDate.getTime
|
||||
val timeOut = time - xTime
|
||||
xTime = time
|
||||
timeOut
|
||||
}
|
||||
case _ =>
|
||||
Nil
|
||||
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
|
||||
}
|
||||
//TODO the math here is not very meaningful
|
||||
math.floor(math.sqrt(
|
||||
math.max(0, takeWhileDelay(restBetweenKills, testValue = 20000L, mercy).size - 1) +
|
||||
math.max(0, takeWhileDelay(restBetweenKills, testValue = 10000L, mercy).size - 5) * 3 +
|
||||
math.max(0, takeWhileDelay(restBetweenKills, testValue = 5000L, mercy = 1000L).size - 4) * 5
|
||||
)).toInt
|
||||
}
|
||||
|
||||
private def takeWhileDelay(list: Iterable[Long], testValue: Long, mercy: Long = 2500L): Iterable[Long] = {
|
||||
/**
|
||||
* 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.takeWhile { time =>
|
||||
if (time < testValue) {
|
||||
list.filter { value =>
|
||||
if (value < testValue) {
|
||||
true
|
||||
} else if (time - onGoingMercy - 1 < testValue) {
|
||||
onGoingMercy = math.ceil(onGoingMercy * 0.65).toLong
|
||||
} 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
|
||||
|
|
@ -93,6 +299,15 @@ object KillAssists {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
|
@ -128,35 +343,20 @@ object KillAssists {
|
|||
}
|
||||
}
|
||||
|
||||
private[exp] def rewardThisPlayerDeath(
|
||||
victim: PlayerSource,
|
||||
lastDamage: Option[DamageResult],
|
||||
history: Iterable[InGameActivity],
|
||||
eventBus: ActorRef
|
||||
): Unit = {
|
||||
val shortHistory = limitHistoryToThisLife(history.toList)
|
||||
val everyone = determineKiller(lastDamage, shortHistory) match {
|
||||
case Some((result, killer: PlayerSource)) =>
|
||||
val assists = collectKillAssistsForPlayer(victim, shortHistory, Some(killer))
|
||||
val fullBep = calculateExperience(killer, victim, shortHistory)
|
||||
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 }, shortHistory.last.time - shortHistory.head.time, fullBep)) +: hitSquad
|
||||
|
||||
case _ =>
|
||||
val assists = collectKillAssistsForPlayer(victim, shortHistory, None)
|
||||
val fullBep = Support.baseExperience(victim, shortHistory)
|
||||
val hitSquad = assists.map {
|
||||
case ContributionStatsOutput(p, w, r) => (p, Assist(victim, w, r, (fullBep * r).toLong))
|
||||
}.toSeq
|
||||
(victim, Death(hitSquad.map { _._1 }, shortHistory.last.time - shortHistory.head.time, fullBep)) +: hitSquad
|
||||
}
|
||||
everyone.foreach { case (p, kda) =>
|
||||
eventBus ! AvatarServiceMessage(p.Name, AvatarAction.UpdateKillsDeathsAssists(p.CharId, kda))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate chronologic 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],
|
||||
|
|
@ -170,7 +370,7 @@ object KillAssists {
|
|||
healthAssists.remove(0L)
|
||||
healthAssists.remove(victim.CharId)
|
||||
killerOpt.map { killer => healthAssists.remove(killer.CharId) }
|
||||
if (Support.wasEverAMax(victim, history)) { //a cardinal sin
|
||||
if (Support.wasEverAMax(victim, history)) {
|
||||
val armorAssists = collectKillAssists(
|
||||
victim,
|
||||
history,
|
||||
|
|
@ -185,11 +385,19 @@ object KillAssists {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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] = {
|
||||
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) =>
|
||||
|
|
@ -199,14 +407,28 @@ object KillAssists {
|
|||
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)<br>
|
||||
* key - character identifier,
|
||||
* value - current damage contribution */
|
||||
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)<br>
|
||||
key - character identifier,
|
||||
value - current damage contribution
|
||||
*/
|
||||
var inOrder: Seq[(Long, Int)] = Seq[(Long, Int)]()
|
||||
history.foreach {
|
||||
case d: DamagingActivity if d.health > 0 =>
|
||||
|
|
@ -220,14 +442,27 @@ object KillAssists {
|
|||
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)<br>
|
||||
* key - character identifier,
|
||||
* value - current damage contribution */
|
||||
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)<br>
|
||||
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 =>
|
||||
|
|
@ -239,6 +474,15 @@ object KillAssists {
|
|||
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,
|
||||
|
|
@ -259,6 +503,16 @@ object KillAssists {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
|
@ -331,6 +585,13 @@ object KillAssists {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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],
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,23 +1,23 @@
|
|||
// Copyright (c) 2023 PSForever
|
||||
package net.psforever.objects.zones.exp
|
||||
|
||||
import net.psforever.objects.GlobalDefinitions
|
||||
|
||||
import java.util.Date
|
||||
import org.joda.time.{Instant, LocalDateTime => JodaLocalDateTime}
|
||||
import net.psforever.objects.serverobject.hackable.Hackable
|
||||
import net.psforever.objects.serverobject.terminals.Terminal
|
||||
import net.psforever.objects.sourcing.{AmenitySource, PlayerSource, SourceEntry}
|
||||
import net.psforever.objects.vital.{HealFromEquipment, InGameActivity, ReconstructionActivity, RepairFromEquipment, RepairFromExoSuitChange, RevivingActivity, SpawningActivity, SupportActivityCausedByAnother, TerminalUsedActivity}
|
||||
import net.psforever.objects.zones.Zone
|
||||
import net.psforever.types.{ExoSuitType, PlanetSideEmpire, TransactionType}
|
||||
import net.psforever.zones.Zones
|
||||
import net.psforever.objects.sourcing.PlayerSource
|
||||
import net.psforever.objects.vital.{InGameActivity, ReconstructionActivity, RepairFromExoSuitChange, SpawningActivity}
|
||||
import net.psforever.types.{ExoSuitType, PlanetSideEmpire}
|
||||
|
||||
import scala.collection.mutable
|
||||
|
||||
/**
|
||||
* Functions to assist experience calculation and history manipulation and analysis.
|
||||
*/
|
||||
object Support {
|
||||
private type SupportActivity = InGameActivity with SupportActivityCausedByAnother
|
||||
|
||||
/**
|
||||
* 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]
|
||||
|
|
@ -26,8 +26,7 @@ object Support {
|
|||
case (Some(spawn), Some(death)) => death.time - spawn.time
|
||||
case _ => 0L
|
||||
}
|
||||
val wasEverAMax = Support.wasEverAMax(victim, history)
|
||||
val base = if (wasEverAMax) { //shamed
|
||||
val base = if (Support.wasEverAMax(victim, history)) {
|
||||
250L
|
||||
} else if (victim.Seated || victim.progress.kills.nonEmpty) {
|
||||
100L
|
||||
|
|
@ -45,20 +44,39 @@ object Support {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
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)
|
||||
defaultAdditiveOutputCombiner(multiplier = 0.05f)
|
||||
): Iterable[ContributionStatsOutput] = {
|
||||
if (second.isEmpty) {
|
||||
first
|
||||
|
|
@ -79,6 +97,13 @@ object Support {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
)
|
||||
|
|
@ -92,206 +117,43 @@ object Support {
|
|||
first.copy(implements = (first.implements ++ second.implements).distinct, percentage = second.percentage + second.implements.size * multiplier)
|
||||
}
|
||||
|
||||
private[exp] def collectHealingSupportAssists(
|
||||
target: SourceEntry,
|
||||
time: JodaLocalDateTime,
|
||||
history: List[InGameActivity]
|
||||
): mutable.LongMap[ContributionStatsOutput] = {
|
||||
//normal heals
|
||||
val healInfo = collectSupportContributions(
|
||||
target, time, history.collect { case heals: HealFromEquipment
|
||||
if heals.amount > 0 && heals.user.unique != target.unique => (heals, heals.equipment_def.ObjectId)
|
||||
}, mapContributionPointsByPercentage
|
||||
)
|
||||
//revivals (count all revivals performed by the player of the last one, if any)
|
||||
val reviveInfo = collectSupportContributions(
|
||||
target, time, history.collect { case revive: RevivingActivity
|
||||
if revive.user.unique != target.unique => (revive, revive.equipment.ObjectId)
|
||||
}.lastOption.toSeq, mapContributionPointsByCount
|
||||
)
|
||||
//combine and output
|
||||
reviveInfo.foreach { case (reviverId, contribution) =>
|
||||
val result = healInfo.get(reviverId)
|
||||
val percentage = result.map { a => a.percentage }.getOrElse { contribution.percentage }
|
||||
val implements = result.map { a => (a.implements ++ contribution.implements).distinct }.getOrElse { contribution.implements }
|
||||
healInfo.put(reviverId, ContributionStatsOutput(contribution.player, implements, percentage))
|
||||
}
|
||||
healInfo
|
||||
}
|
||||
|
||||
private[exp] def collectRepairingSupportAssists(
|
||||
target: SourceEntry,
|
||||
time: JodaLocalDateTime,
|
||||
history: List[InGameActivity]
|
||||
): mutable.LongMap[ContributionStatsOutput] = {
|
||||
collectSupportContributions(
|
||||
target,
|
||||
time,
|
||||
history.collect { case repairs: RepairFromEquipment
|
||||
if repairs.amount > 0 && repairs.user.unique != target.unique => (repairs, repairs.equipment_def.ObjectId)
|
||||
},
|
||||
mapContributionPointsByPercentage
|
||||
)
|
||||
}
|
||||
|
||||
private[exp] def collectTerminalSupportAssists(
|
||||
target: SourceEntry,
|
||||
history: List[InGameActivity]
|
||||
): Iterable[ContributionStatsOutput] = {
|
||||
val delay: Long = 300000L
|
||||
val curr = System.currentTimeMillis()
|
||||
var zone: Zone = Zone.Nowhere
|
||||
val termsUsed: mutable.LongMap[Option[AmenitySource]] = mutable.LongMap[Option[AmenitySource]]()
|
||||
val credit: mutable.LongMap[ContributionStatsOutput] = mutable.LongMap[ContributionStatsOutput]()
|
||||
val faction = target.Faction
|
||||
val terminalUseHistory = history.filter {
|
||||
case _: SpawningActivity => true
|
||||
case entry: TerminalUsedActivity => curr - entry.time < delay
|
||||
case _ => false
|
||||
}
|
||||
terminalUseHistory.collect {
|
||||
case entry: SpawningActivity =>
|
||||
zone = Zones.zones(entry.zoneNumber)
|
||||
case entry: TerminalUsedActivity
|
||||
if !termsUsed.contains(entry.terminal.unique.guid.guid.toLong) =>
|
||||
if (
|
||||
entry.transaction == TransactionType.Loadout ||
|
||||
entry.transaction == TransactionType.Buy ||
|
||||
entry.transaction == TransactionType.Learn
|
||||
) {
|
||||
val terminal = entry.terminal
|
||||
val guid = terminal.unique.guid.guid
|
||||
terminal.hacked.collect {
|
||||
case Hackable.HackInfo(player, _, _, _)
|
||||
if target.CharId != player.CharId && faction == player.Faction =>
|
||||
//accessed a hacked terminal
|
||||
termsUsed.put(guid.toLong, Some(terminal))
|
||||
addTerminalContributionEntry(credit, player, Seq(HackKillAssist(GlobalDefinitions.remote_electronics_kit.ObjectId)), percentage = 1f)
|
||||
case _
|
||||
if terminal.Faction == faction =>
|
||||
//accessed a faction-friendly terminal; check log for repair history
|
||||
termsUsed.put(guid.toLong, Some(terminal))
|
||||
zone.GUID(guid)
|
||||
.asInstanceOf[Terminal]
|
||||
.History
|
||||
.collect { case a: RepairFromEquipment =>
|
||||
val user = a.user
|
||||
//TODO might be wrong intermediate
|
||||
addTerminalContributionEntry(credit, user, Seq(RepairKillAssist(a.equipment_def.ObjectId, target.Definition.ObjectId)), percentage = 0.5f)
|
||||
}
|
||||
case _ =>
|
||||
//what is this?
|
||||
termsUsed.put(guid.toLong, None)
|
||||
}
|
||||
}
|
||||
}
|
||||
credit.remove(target.CharId)
|
||||
credit.values
|
||||
}
|
||||
|
||||
private def addTerminalContributionEntry(
|
||||
contributions: mutable.LongMap[ContributionStatsOutput],
|
||||
player: PlayerSource,
|
||||
implements: Seq[EquipmentUseContextWrapper],
|
||||
percentage: Float
|
||||
): Unit = {
|
||||
val charId = player.CharId
|
||||
contributions.get(charId) match {
|
||||
case Some(entry) if percentage > entry.percentage =>
|
||||
contributions.put(charId, entry.copy(percentage = percentage))
|
||||
case Some(entry) =>
|
||||
contributions.put(charId, entry.copy(implements = (entry.implements ++ implements).distinct, percentage = entry.percentage + 0.05f))
|
||||
case None =>
|
||||
contributions.put(charId, ContributionStatsOutput(player, implements, percentage))
|
||||
}
|
||||
}
|
||||
|
||||
private[exp] def findTimeApplicableActivities(
|
||||
activity: Seq[SupportActivity],
|
||||
killLastTime: JodaLocalDateTime,
|
||||
timeOffset: Int //s
|
||||
): Seq[SupportActivity] = {
|
||||
val dateTimeConverter: Date=>JodaLocalDateTime = JodaLocalDateTime.fromDateFields
|
||||
val milliToInstant: Long=>org.joda.time.Instant = Instant.ofEpochMilli
|
||||
val targetTime = killLastTime.minusSeconds(timeOffset)
|
||||
//find all activities that occurred after the kill time after the offset is considered
|
||||
activity
|
||||
.filter { a =>
|
||||
val activityTime = dateTimeConverter(milliToInstant(a.time).toDate)
|
||||
targetTime.isBefore(activityTime) || targetTime.equals(activityTime)
|
||||
}
|
||||
.sortBy(_.time)(Ordering.Long.reverse)
|
||||
}
|
||||
|
||||
private def combineTimeApplicableActivitiesForContribution(
|
||||
longTerm: Seq[SupportActivity],
|
||||
shortTerm: Seq[SupportActivity],
|
||||
defaultTool: Int
|
||||
): mutable.LongMap[ContributionStats] = {
|
||||
val contributions: mutable.LongMap[ContributionStats] = mutable.LongMap[ContributionStats]()
|
||||
(longTerm ++ shortTerm).foreach { h =>
|
||||
val amount = h.amount
|
||||
val user = h.user
|
||||
val charId = user.CharId
|
||||
contributions.get(charId) match {
|
||||
case Some(entry) =>
|
||||
contributions.update(charId, entry.copy(
|
||||
amount = entry.amount + amount,
|
||||
total = entry.total + amount,
|
||||
shots = entry.shots + 1,
|
||||
time = math.max(entry.time, h.time)
|
||||
))
|
||||
case None =>
|
||||
//the contribution percentage allocated will always be 1.0f and should be overwritten later
|
||||
contributions.put(charId, ContributionStats(user, Seq(WeaponStats(NoUse(defaultTool), amount, 1, h.time, 1f)), amount, amount, 1, h.time))
|
||||
}
|
||||
}
|
||||
contributions
|
||||
}
|
||||
|
||||
private def collectSupportContributions(
|
||||
target: SourceEntry,
|
||||
time: JodaLocalDateTime,
|
||||
pairs: Seq[(InGameActivity, Int)],
|
||||
contributionPointsMap: (Float,Iterable[SupportActivity])=>(Long,ContributionStats)=>(Long,ContributionStatsOutput)
|
||||
): mutable.LongMap[ContributionStatsOutput] = {
|
||||
val (activity, tools) = pairs.unzip
|
||||
//TODO this is not correct, but it will do for now
|
||||
if (tools.isEmpty) {
|
||||
mutable.LongMap[ContributionStatsOutput]()
|
||||
} else {
|
||||
val distinctTools = tools.distinct
|
||||
if (distinctTools.size == 1) {
|
||||
compileTimedSupportContributions(target, time, activity, distinctTools.head, contributionPointsMap)
|
||||
} else {
|
||||
var output = Iterable[ContributionStatsOutput]()
|
||||
distinctTools.foreach { implement =>
|
||||
output = onlyOriginalAssistEntriesIterable(
|
||||
output,
|
||||
compileTimedSupportContributions(target, time, activity, implement, contributionPointsMap).values
|
||||
/**
|
||||
* 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)
|
||||
)
|
||||
}
|
||||
mutable.LongMap[ContributionStatsOutput]().addAll(output.map { entry => (entry.player.CharId, entry) })
|
||||
}
|
||||
}
|
||||
firstAlone ++ secondAlone ++ combined
|
||||
}
|
||||
|
||||
private def compileTimedSupportContributions(
|
||||
target: SourceEntry,
|
||||
timeLimit: JodaLocalDateTime,
|
||||
activity: Seq[InGameActivity],
|
||||
defaultImplement: Int,
|
||||
contributionPointsMap: (Float,Iterable[SupportActivity])=>(Long,ContributionStats)=>(Long,ContributionStatsOutput)
|
||||
): mutable.LongMap[ContributionStatsOutput] = {
|
||||
val activityByAnother = activity.collect { case theActivity: SupportActivity => theActivity }
|
||||
val longTermActivity = findTimeApplicableActivities(activityByAnother, timeLimit, timeOffset = 600)
|
||||
val shortTermActivity = findTimeApplicableActivities(activityByAnother, timeLimit, timeOffset = 300)
|
||||
val contributions = combineTimeApplicableActivitiesForContribution(longTermActivity, shortTermActivity, defaultImplement)
|
||||
contributions.remove(target.CharId)
|
||||
val mapFunc = contributionPointsMap(contributions.values.foldLeft(0)(_ + _.total).toFloat, shortTermActivity)
|
||||
contributions.map { case (a, b) => mapFunc(a, b) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
)
|
||||
|
|
@ -299,57 +161,24 @@ object Support {
|
|||
history: List[InGameActivity],
|
||||
faction: PlanetSideEmpire.Value
|
||||
): mutable.LongMap[ContributionStats] = {
|
||||
/** players who have contributed to this death, and how much they have contributed<br>
|
||||
* key - character identifier,
|
||||
* value - (player, damage, total damage, number of shots) */
|
||||
/*
|
||||
players who have contributed to this death, and how much they have contributed<br>
|
||||
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 mapContributionPointsByPercentage(
|
||||
total: Float,
|
||||
compareList: Iterable[SupportActivity]
|
||||
)
|
||||
(
|
||||
charId: Long,
|
||||
contribution: ContributionStats,
|
||||
): (Long, ContributionStatsOutput) = {
|
||||
val user = contribution.player
|
||||
val unique = user.unique
|
||||
val points = contribution.amount
|
||||
val value = if (points < 75) {
|
||||
//a small contribution means the lower time limit
|
||||
if (compareList.exists { a => a.user.unique == unique }) {
|
||||
math.max(0.2f, points / total)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
} else {
|
||||
//large contribution is always okay
|
||||
if (points > 299) {
|
||||
1.0f
|
||||
} else if (points > 100) {
|
||||
0.75f
|
||||
} else {
|
||||
0.5f
|
||||
}
|
||||
}
|
||||
(charId, ContributionStatsOutput(user, contribution.weapons.map { _.equipment }, value))
|
||||
}
|
||||
|
||||
//noinspection ScalaUnusedSymbol
|
||||
private def mapContributionPointsByCount(
|
||||
total: Float,
|
||||
compareList: Iterable[SupportActivity]
|
||||
)
|
||||
(
|
||||
charId: Long,
|
||||
contribution: ContributionStats,
|
||||
): (Long, ContributionStatsOutput) = {
|
||||
(charId, ContributionStatsOutput(contribution.player, contribution.weapons.map { _.equipment }, compareList.size.toFloat))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
|
|
|||
|
|
@ -105,32 +105,17 @@ object ToDatabase {
|
|||
* Shots fired.
|
||||
*/
|
||||
def reportToolDischarge(avatarId: Long, stats: EquipmentStat): Unit = {
|
||||
val result = for {
|
||||
res <- ctx.run(
|
||||
query[persistence.Weaponstatsession]
|
||||
.filter(_.avatarId == lift(avatarId))
|
||||
.filter(_.weaponId == lift(stats.objectId))
|
||||
.update(
|
||||
_.shotsFired -> lift(stats.shotsFired),
|
||||
_.shotsLanded -> lift(stats.shotsLanded)
|
||||
)
|
||||
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)
|
||||
)
|
||||
} yield res
|
||||
result.onComplete {
|
||||
case Success(rowCount) if rowCount.longValue > 0 => ()
|
||||
case _ =>
|
||||
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)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,16 +2,12 @@
|
|||
package net.psforever.objects.zones.exp.rec
|
||||
|
||||
import net.psforever.objects.sourcing.SourceUniqueness
|
||||
import net.psforever.objects.vital.{DamagingActivity, InGameActivity, RepairFromEquipment, RepairFromTerminal, RepairingActivity, SupportActivityCausedByAnother}
|
||||
import net.psforever.objects.zones.exp.KillContributions
|
||||
import net.psforever.objects.vital.{DamagingActivity, InGameActivity, RepairFromEquipment, RepairingActivity}
|
||||
import net.psforever.types.PlanetSideEmpire
|
||||
|
||||
import scala.collection.mutable
|
||||
|
||||
class ArmorRecoveryExperienceContributionProcess(
|
||||
private val faction : PlanetSideEmpire.Value,
|
||||
private val contributions: Map[SourceUniqueness, List[InGameActivity]],
|
||||
private val excludedTargets: mutable.ListBuffer[SourceUniqueness] = mutable.ListBuffer()
|
||||
private val contributions: Map[SourceUniqueness, List[InGameActivity]]
|
||||
) extends RecoveryExperienceContributionProcess(faction, contributions) {
|
||||
def submit(history: List[InGameActivity]): Unit = {
|
||||
history.foreach {
|
||||
|
|
@ -26,31 +22,6 @@ class ArmorRecoveryExperienceContributionProcess(
|
|||
)
|
||||
damageInOrder = damage
|
||||
recoveryInOrder = recovery
|
||||
case rt: RepairFromTerminal =>
|
||||
val time = rt.time
|
||||
val users = KillContributions.contributeWithTerminalActivity(
|
||||
Seq((rt, rt.term, rt.term.hacked)),
|
||||
faction,
|
||||
contributions,
|
||||
contributionsBy,
|
||||
excludedTargets
|
||||
)
|
||||
.collect { case entry: SupportActivityCausedByAnother => entry }
|
||||
.groupBy(_.user.unique)
|
||||
.map(_._2.head.user)
|
||||
.toSeq
|
||||
val (damage, recovery) = RecoveryExperienceContribution.contributeWithSupportRecoveryActivity(
|
||||
users,
|
||||
rt.term.Definition.ObjectId,
|
||||
faction,
|
||||
rt.amount,
|
||||
time,
|
||||
participants,
|
||||
damageInOrder,
|
||||
recoveryInOrder
|
||||
)
|
||||
damageInOrder = damage
|
||||
recoveryInOrder = recovery
|
||||
case r: RepairFromEquipment =>
|
||||
val (damage, recovery) = RecoveryExperienceContribution.contributeWithRecoveryActivity(
|
||||
r.user,
|
||||
|
|
|
|||
|
|
@ -13,10 +13,9 @@ class CombinedHealthAndArmorContributionProcess(
|
|||
private val contributions: Map[SourceUniqueness, List[InGameActivity]],
|
||||
otherSubmissions: Seq[RecoveryExperienceContribution]
|
||||
) extends RecoveryExperienceContribution {
|
||||
private val excludedTargets: mutable.ListBuffer[SourceUniqueness] = mutable.ListBuffer()
|
||||
private val process: Seq[RecoveryExperienceContributionProcess] = Seq(
|
||||
new HealthRecoveryExperienceContributionProcess(faction, contributions, excludedTargets),
|
||||
new ArmorRecoveryExperienceContributionProcess(faction, contributions, excludedTargets)
|
||||
new HealthRecoveryExperienceContributionProcess(faction, contributions),
|
||||
new ArmorRecoveryExperienceContributionProcess(faction, contributions)
|
||||
)
|
||||
|
||||
def submit(history: List[InGameActivity]): Unit = {
|
||||
|
|
|
|||
|
|
@ -2,16 +2,12 @@
|
|||
package net.psforever.objects.zones.exp.rec
|
||||
|
||||
import net.psforever.objects.sourcing.SourceUniqueness
|
||||
import net.psforever.objects.vital.{DamagingActivity, HealFromEquipment, HealFromTerminal, HealingActivity, InGameActivity, RevivingActivity, SupportActivityCausedByAnother}
|
||||
import net.psforever.objects.zones.exp.KillContributions
|
||||
import net.psforever.objects.vital.{DamagingActivity, HealFromEquipment, HealingActivity, InGameActivity, RevivingActivity}
|
||||
import net.psforever.types.PlanetSideEmpire
|
||||
|
||||
import scala.collection.mutable
|
||||
|
||||
private class HealthRecoveryExperienceContributionProcess(
|
||||
private val faction : PlanetSideEmpire.Value,
|
||||
private val contributions: Map[SourceUniqueness, List[InGameActivity]],
|
||||
private val excludedTargets: mutable.ListBuffer[SourceUniqueness] = mutable.ListBuffer()
|
||||
private val contributions: Map[SourceUniqueness, List[InGameActivity]]
|
||||
) extends RecoveryExperienceContributionProcess(faction, contributions) {
|
||||
def submit(history: List[InGameActivity]): Unit = {
|
||||
history.foreach {
|
||||
|
|
@ -26,31 +22,6 @@ private class HealthRecoveryExperienceContributionProcess(
|
|||
)
|
||||
damageInOrder = damage
|
||||
recoveryInOrder = recovery
|
||||
case ht: HealFromTerminal =>
|
||||
val time = ht.time
|
||||
val users = KillContributions.contributeWithTerminalActivity(
|
||||
Seq((ht, ht.term, ht.term.hacked)),
|
||||
faction,
|
||||
contributions,
|
||||
contributionsBy,
|
||||
excludedTargets
|
||||
)
|
||||
.collect { case entry: SupportActivityCausedByAnother => entry }
|
||||
.groupBy(_.user.unique)
|
||||
.map(_._2.head.user)
|
||||
.toSeq
|
||||
val (damage, recovery) = RecoveryExperienceContribution.contributeWithSupportRecoveryActivity(
|
||||
users,
|
||||
ht.term.Definition.ObjectId,
|
||||
faction,
|
||||
ht.amount,
|
||||
time,
|
||||
participants,
|
||||
damageInOrder,
|
||||
recoveryInOrder
|
||||
)
|
||||
damageInOrder = damage
|
||||
recoveryInOrder = recovery
|
||||
case h: HealFromEquipment =>
|
||||
val (damage, recovery) = RecoveryExperienceContribution.contributeWithRecoveryActivity(
|
||||
h.user,
|
||||
|
|
|
|||
|
|
@ -3,22 +3,23 @@ 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
|
||||
import net.psforever.types.PlanetSideEmpire
|
||||
|
||||
import scala.collection.mutable
|
||||
|
||||
//noinspection ScalaUnusedSymbol
|
||||
class MachineRecoveryExperienceContributionProcess(
|
||||
private val faction : PlanetSideEmpire.Value,
|
||||
private val contributions: Map[SourceUniqueness, List[InGameActivity]],
|
||||
private val excludedTargets: mutable.ListBuffer[SourceUniqueness] = mutable.ListBuffer()
|
||||
) extends RecoveryExperienceContributionProcess(faction, contributions) {
|
||||
private val faction : PlanetSideEmpire.Value,
|
||||
private val contributions: Map[SourceUniqueness, List[InGameActivity]],
|
||||
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 > 0 =>
|
||||
case d: DamagingActivity if d.amount == d.health =>
|
||||
val (damage, recovery) = RecoveryExperienceContribution.contributeWithDamagingActivity(
|
||||
d,
|
||||
d.amount - d.health,
|
||||
d.health,
|
||||
damageParticipants,
|
||||
participants,
|
||||
damageInOrder,
|
||||
|
|
@ -26,7 +27,7 @@ class MachineRecoveryExperienceContributionProcess(
|
|||
)
|
||||
damageInOrder = damage
|
||||
recoveryInOrder = recovery
|
||||
case r: RepairFromEquipment =>
|
||||
case r: RepairFromEquipment if !excludedTargets.contains(r.user.unique) =>
|
||||
val (damage, recovery) = RecoveryExperienceContribution.contributeWithRecoveryActivity(
|
||||
r.user,
|
||||
r.equipment_def.ObjectId,
|
||||
|
|
@ -56,4 +57,23 @@ class MachineRecoveryExperienceContributionProcess(
|
|||
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
|
||||
entries.head.copy(
|
||||
shots = size,
|
||||
amount = entries.foldLeft(0)(_ + _.amount),
|
||||
contributions = (10 + size).toFloat,
|
||||
time = newTime
|
||||
)
|
||||
}
|
||||
.toSeq
|
||||
(id, stats.copy(weapons = weps))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -262,18 +262,20 @@ object RecoveryExperienceContribution {
|
|||
): (Seq[(Long, Int)], Seq[(Long, Int)]) = {
|
||||
var outputDamageOrder = damageOrder
|
||||
var outputRecoveryOrder = recoveryOrder
|
||||
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
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ import scala.collection.mutable
|
|||
|
||||
//noinspection ScalaUnusedSymbol
|
||||
abstract class RecoveryExperienceContributionProcess(
|
||||
faction : PlanetSideEmpire.Value,
|
||||
contributions: Map[SourceUniqueness, List[InGameActivity]]
|
||||
) extends RecoveryExperienceContribution {
|
||||
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]()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue