diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf
index 20885b9e5..86ac54b7f 100644
--- a/src/main/resources/application.conf
+++ b/src/main/resources/application.conf
@@ -154,6 +154,20 @@ game {
force-rotation-immediately = false
}
+ virtual-training = {
+ # Enables VR Shooting Range targets, if you know the VR Shooting Range is not going to be used,
+ # you can disable target spawns to slightly reduce the server's baseline CPU and memory usage.
+ shooting-range-targets-enabled = true
+
+ # You can customize the list of VR Shooting Range bot names here.
+ # The default names are all names that were observed from the original servers.
+ male-bot-names = ["Aphtek", "Arrius", "Augustus", "Batezek", "Baulk", "Bayloon", "Bentar", "Braydon", "Brok", "Brutus", "Byatis", "Cantrip", "Doragon", "Elion", "Garbazhu", "GAxir", "Ghurmak", "Grailbait", "Graznack", "Gorabbus", "Gorganogan", "Hlanith", "Jitrick", "Julianus", "Knyan", "Lupus", "Marcus", "Morkardar", "Morungat", "Murch", "Nerick", "Nezcaro", "Plinius", "Prondus", "Publius", "Quillion", "Remus", "Secarr", "Shantak", "Shoggoth", "Shulogrem", "Taractus", "Tazok", "Thran", "Thremnir", "Urzican", "Zorach", "Yarblek", "Yith", "Yoth"]
+
+ female-bot-names = ["Agatz", "Aggan", "Arham", "Borador", "Brrt", "Cahh", "Ceth", "Chazor", "Cherik", "Corax", "Cyaegha", "Cykranosh", "Dentarg", "Drask", "Fathrd", "Ghorak", "Grogdish", "Grotta", "Gorman", "Hurrbrrmn", "Hrrd", "Ithak", "Julius", "Kephnes", "Kherek", "Korros", "Kraa", "Kradak", "Kryle", "Marcius", "Melderthra", "Minx", "Mnar", "Nanak", "Ngranek", "Nir", "Nyariathotep", "Sharagar", "Sharrak", "Shkar", "Ssaad", "Ssikz", "Tarach", "Tarkacho", "Tassadar", "Thraa", "Thurraz", "Tyr", "Utz", "Whea", "Yakkaz", "Yarak", "Zadrak"]
+
+ universal-bot-names = ["Bane", "Barzai", "BoacTreth", "Brazoragh", "Cicero", "Chamak", "Chrkss", "Cshsssh", "DylathLeen", "Gnomon", "GrizzleBok", "Hatheg", "Izch", "Jazzkkrt", "Mogor", "Myst", "Nero", "OothNarga", "Orrg", "Shadow", "TchoTcho", "Twilight", "Vthak", "Vulthoom", "Ychch", "Zar", "Zhar"]
+ }
+
saved-msg = {
# A brief delay to the @charsaved message, in seconds.
# Use this when the message should display very soon for any reason.
diff --git a/src/main/resources/guid-pools/tzshnc.json b/src/main/resources/guid-pools/tzshnc.json
new file mode 100644
index 000000000..657ed7965
--- /dev/null
+++ b/src/main/resources/guid-pools/tzshnc.json
@@ -0,0 +1,14 @@
+[
+ {
+ "Name": "environment",
+ "Start": 0,
+ "Max": 896,
+ "Selector": "specific"
+ },
+ {
+ "Name": "bots",
+ "Start": 1300,
+ "Max": 1600,
+ "Selector": "random"
+ }
+]
diff --git a/src/main/resources/guid-pools/tzshtr.json b/src/main/resources/guid-pools/tzshtr.json
new file mode 100644
index 000000000..657ed7965
--- /dev/null
+++ b/src/main/resources/guid-pools/tzshtr.json
@@ -0,0 +1,14 @@
+[
+ {
+ "Name": "environment",
+ "Start": 0,
+ "Max": 896,
+ "Selector": "specific"
+ },
+ {
+ "Name": "bots",
+ "Start": 1300,
+ "Max": 1600,
+ "Selector": "random"
+ }
+]
diff --git a/src/main/resources/guid-pools/tzshvs.json b/src/main/resources/guid-pools/tzshvs.json
new file mode 100644
index 000000000..657ed7965
--- /dev/null
+++ b/src/main/resources/guid-pools/tzshvs.json
@@ -0,0 +1,14 @@
+[
+ {
+ "Name": "environment",
+ "Start": 0,
+ "Max": 896,
+ "Selector": "specific"
+ },
+ {
+ "Name": "bots",
+ "Start": 1300,
+ "Max": 1600,
+ "Selector": "random"
+ }
+]
diff --git a/src/main/scala/net/psforever/actors/session/csr/GeneralLogic.scala b/src/main/scala/net/psforever/actors/session/csr/GeneralLogic.scala
index 31ca715e4..4f168065b 100644
--- a/src/main/scala/net/psforever/actors/session/csr/GeneralLogic.scala
+++ b/src/main/scala/net/psforever/actors/session/csr/GeneralLogic.scala
@@ -5,7 +5,7 @@ import akka.actor.{ActorContext, ActorRef, typed}
import net.psforever.actors.session.AvatarActor
import net.psforever.actors.session.support.{GeneralFunctions, GeneralOperations, SessionData}
import net.psforever.objects.{Account, BoomerDeployable, BoomerTrigger, ConstructionItem, GlobalDefinitions, LivePlayerList, Player, SensorDeployable, ShieldGeneratorDeployable, SpecialEmp, TelepadDeployable, Tool, TrapDeployable, TurretDeployable, Vehicle}
-import net.psforever.objects.avatar.{Avatar, PlayerControl}
+import net.psforever.objects.avatar.{Avatar, AvatarBot, PlayerControl}
import net.psforever.objects.ballistics.Projectile
import net.psforever.objects.ce.Deployable
import net.psforever.objects.definition.{BasicDefinition, KitDefinition, SpecialExoSuitDefinition}
@@ -286,6 +286,8 @@ class GeneralLogic(val ops: GeneralOperations, implicit val context: ActorContex
ops.handleUseGeneralEntity(panel, equipment)
case Some(obj: Player) =>
ops.handleUsePlayer(obj, equipment, pkt)
+ case Some(obj: AvatarBot) =>
+ ops.handleUseBot(obj, equipment, pkt)
case Some(locker: Locker) =>
ops.handleUseLocker(locker, equipment, pkt)
case Some(gen: Generator) =>
diff --git a/src/main/scala/net/psforever/actors/session/normal/GeneralLogic.scala b/src/main/scala/net/psforever/actors/session/normal/GeneralLogic.scala
index 823d09923..af2f9a693 100644
--- a/src/main/scala/net/psforever/actors/session/normal/GeneralLogic.scala
+++ b/src/main/scala/net/psforever/actors/session/normal/GeneralLogic.scala
@@ -6,7 +6,7 @@ import akka.actor.{ActorContext, ActorRef, typed}
import net.psforever.actors.session.{AvatarActor, SessionActor}
import net.psforever.actors.session.support.{GeneralFunctions, GeneralOperations, SessionData, SessionOutfitHandlers}
import net.psforever.objects.{Account, BoomerDeployable, BoomerTrigger, ConstructionItem, GlobalDefinitions, LivePlayerList, Player, SensorDeployable, ShieldGeneratorDeployable, SpecialEmp, TelepadDeployable, Tool, TrapDeployable, TurretDeployable, Vehicle}
-import net.psforever.objects.avatar.{Avatar, PlayerControl, SpecialCarry}
+import net.psforever.objects.avatar.{Avatar, AvatarBot, PlayerControl, SpecialCarry}
import net.psforever.objects.ballistics.Projectile
import net.psforever.objects.ce.{Deployable, DeployedItem}
import net.psforever.objects.definition.{BasicDefinition, KitDefinition, SpecialExoSuitDefinition}
@@ -356,6 +356,8 @@ class GeneralLogic(val ops: GeneralOperations, implicit val context: ActorContex
ops.handleUseGeneralEntity(panel, equipment)
case Some(obj: Player) =>
ops.handleUsePlayer(obj, equipment, pkt)
+ case Some(obj: AvatarBot) =>
+ ops.handleUseBot(obj, equipment, pkt)
case Some(locker: Locker) =>
ops.handleUseLocker(locker, equipment, pkt)
case Some(gen: Generator) =>
diff --git a/src/main/scala/net/psforever/actors/session/support/GeneralOperations.scala b/src/main/scala/net/psforever/actors/session/support/GeneralOperations.scala
index a9692605a..76459ff0a 100644
--- a/src/main/scala/net/psforever/actors/session/support/GeneralOperations.scala
+++ b/src/main/scala/net/psforever/actors/session/support/GeneralOperations.scala
@@ -2,6 +2,7 @@
package net.psforever.actors.session.support
import akka.actor.{ActorContext, ActorRef, Cancellable, typed}
+import net.psforever.objects.avatar.AvatarBot
import net.psforever.objects.serverobject.containable.Containable
import net.psforever.objects.serverobject.doors.Door
import net.psforever.objects.serverobject.interior.Sidedness
@@ -393,9 +394,17 @@ class GeneralOperations(
0
}
Some(TargetInfo(player.GUID, health, armor))
+ case Some(bot: AvatarBot) =>
+ val health = bot.Health.toFloat / bot.MaxHealth
+ val armor = if (bot.MaxArmor > 0) {
+ bot.Armor.toFloat / bot.MaxArmor
+ } else {
+ 0
+ }
+ Some(TargetInfo(bot.GUID, health, armor))
case _ =>
log.warn(
- s"TargetingImplantRequest: the info that ${player.Name} requested for target ${x.target_guid} is not for a player"
+ s"TargetingImplantRequest: the info that ${player.Name} requested for target ${x.target_guid} is not for a player or bot"
)
None
}
@@ -1060,9 +1069,7 @@ class GeneralOperations(
}
def trainingGriefWarning(): Unit = {
- //don't know the correct ChatMessageType for this one yet;
- //it's supposed to use a pop-up that takes 5 seconds before you are allowed to close it, so disabled for now
- //sendResponse(ChatMsg(ChatMessageType.CMT_QUIT, wideContents=true, "", "@TrainingGriefWarning", None))
+ sendResponse(GenericActionMessage(GenericAction.TrainingGriefWarning))
}
def noVoicedChat(pkt: PlanetSideGamePacket): Unit = {
@@ -1199,6 +1206,27 @@ class GeneralOperations(
}
}
+ def handleUseBot(obj: AvatarBot, equipment: Option[Equipment], msg: UseItemMessage): Unit = {
+ sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_use")
+ if (msg.unk3) {
+ msg.object_id match {
+ case ObjectClass.avatar | ObjectClass.avatar_bot | ObjectClass.avatar_bot_agile | ObjectClass.avatar_bot_agile_no_weapon |
+ ObjectClass.avatar_bot_max | ObjectClass.avatar_bot_max_no_weapon | ObjectClass.avatar_bot_reinforced |
+ ObjectClass.avatar_bot_reinforced_no_weapon | ObjectClass.avatar_bot_standard | ObjectClass.avatar_bot_standard_no_weapon =>
+ equipment match {
+ case Some(tool: Tool) if tool.Definition == GlobalDefinitions.bank =>
+ obj.Actor ! CommonMessages.Use(player, equipment)
+
+ case Some(tool: Tool) if tool.Definition == GlobalDefinitions.medicalapplicator =>
+ obj.Actor ! CommonMessages.Use(player, equipment)
+ case _ => ()
+ }
+
+ case _ =>
+ }
+ }
+ }
+
def handleUseLocker(locker: Locker, equipment: Option[Equipment], msg: UseItemMessage): Unit = {
equipment match {
case Some(item) =>
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 09a6e6725..33b838102 100644
--- a/src/main/scala/net/psforever/actors/session/support/SessionData.scala
+++ b/src/main/scala/net/psforever/actors/session/support/SessionData.scala
@@ -422,14 +422,25 @@ class SessionData(
if (obj.spectator && obj != player) {
administrativeKick(player)
} else {
- if (obj.IsInVRZone && obj.Faction == player.Faction && obj.CharId != player.CharId) {
- //don't do friendly-fire in VR zones
- general.trainingGriefWarning()
+ if (obj.IsInVRZone && obj.Faction == player.Faction) {
+ //disable self-damage and friendly-fire in VR zones
+ if (obj.CharId != player.CharId) {
+ general.trainingGriefWarning()
+ }
} else {
obj.Actor ! Vitality.Damage(func)
}
}
+ case obj: AvatarBot if obj.CanDamage && obj.Actor != Default.Actor =>
+ log.info(s"${player.Name} is attacking ${obj.Name}")
+ if (obj.IsInVRZone && obj.Faction == player.Faction) {
+ //disable friendly-fire in VR zones
+ general.trainingGriefWarning()
+ } else {
+ obj.Actor ! Vitality.Damage(func)
+ }
+
case obj: Vehicle if obj.CanDamage =>
val name = player.Name
val ownerName = obj.OwnerName.getOrElse("someone")
@@ -438,17 +449,18 @@ class SessionData(
} else {
log.info(s"$name is attacking $ownerName's ${obj.Definition.Name}")
}
- if (obj.IsInVRZone && obj.Faction == player.Faction && !ownerName.equals(name)) {
- //don't do friendly-fire in VR zones
- general.trainingGriefWarning()
+ if (obj.IsInVRZone && obj.Faction == player.Faction) {
+ //disable self-damage and friendly-fire in VR zones
+ if (!ownerName.equals(name)) {
+ general.trainingGriefWarning()
+ }
} else {
obj.Actor ! Vitality.Damage(func)
}
case obj: Amenity if obj.CanDamage =>
if (obj.IsInVRZone && obj.Faction == player.Faction) {
- //don't do friendly-fire in VR zones
- general.trainingGriefWarning()
+ //disable friendly-fire in VR zones
} else {
obj.Actor ! Vitality.Damage(func)
}
@@ -461,12 +473,7 @@ class SessionData(
} else {
log.info(s"$name is attacking $ownerName's ${obj.Definition.Name}")
}
- if (obj.IsInVRZone && obj.Faction == player.Faction && !ownerName.equals(name)) {
- //don't do friendly-fire in VR zones
- general.trainingGriefWarning()
- } else {
- obj.Actor ! Vitality.Damage(func)
- }
+ obj.Actor ! Vitality.Damage(func)
case _ => ()
}
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 2e32d426a..987331c4c 100644
--- a/src/main/scala/net/psforever/actors/session/support/WeaponAndProjectileOperations.scala
+++ b/src/main/scala/net/psforever/actors/session/support/WeaponAndProjectileOperations.scala
@@ -725,7 +725,7 @@ class WeaponAndProjectileOperations(
proxy.Position = hitPos
proxy.WhichSide = Sidedness.StrictlyBetweenSides
val radiusSquared = proxy.profile.LashRadius * proxy.profile.LashRadius
- var availableTargets = sessionLogic.localSector.livePlayerList
+ var availableTargets = sessionLogic.localSector.livePlayerList ++ sessionLogic.localSector.botList
var unresolvedChainLashHits: Seq[VolumetricGeometry] = Seq(Point(hitPos))
var uniqueChainLashTargets: Seq[(PlanetSideGameObject with FactionAffinity with Vitality, Projectile)] = Seq()
while (unresolvedChainLashHits.nonEmpty) {
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 bd63e19f8..12f80e1fc 100644
--- a/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala
+++ b/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala
@@ -352,6 +352,9 @@ class ZoningOperations(
.foreach { targetPlayer =>
sendResponse(PlanetsideAttributeMessage(targetPlayer.GUID, 1, 120))
}
+ //load active bots in zone
+ val bots = continent.BotAvatars
+ bots.foreach(bot => sendResponse(OCM.apply(bot)))
//load corpses in zone
continent.Corpses.foreach {
spawn.DepictPlayerAsCorpse
@@ -887,30 +890,18 @@ class ZoningOperations(
RequestSanctuaryZoneSpawn(player, player.Zone.Number)
// leveraging SetZone for now because the VR zones have no "proper" spawn points configured yet
case 17 =>
- if (player.Zone.id != "tzshtr") {
- context.self ! SessionActor.SetZone("tzshtr", vrShootingZoneSpawns.toArray.apply(rand.nextInt(vrShootingZoneSpawns.size)))
- }
+ context.self ! SessionActor.SetZone("tzshtr", vrShootingZoneSpawns.toArray.apply(rand.nextInt(vrShootingZoneSpawns.size)))
case 18 =>
- if (player.Zone.id != "tzshnc") {
- context.self ! SessionActor.SetZone("tzshnc", vrShootingZoneSpawns.toArray.apply(rand.nextInt(vrShootingZoneSpawns.size)))
- }
+ context.self ! SessionActor.SetZone("tzshnc", vrShootingZoneSpawns.toArray.apply(rand.nextInt(vrShootingZoneSpawns.size)))
case 19 =>
- if (player.Zone.id != "tzshvs") {
- context.self ! SessionActor.SetZone("tzshvs", vrShootingZoneSpawns.toArray.apply(rand.nextInt(vrShootingZoneSpawns.size)))
- }
+ context.self ! SessionActor.SetZone("tzshvs", vrShootingZoneSpawns.toArray.apply(rand.nextInt(vrShootingZoneSpawns.size)))
case 20 =>
- if (player.Zone.id != "tzdrtr") {
- context.self ! SessionActor.SetZone("tzdrtr", vrDrivingAreaSpawns.toArray.apply(rand.nextInt(vrDrivingAreaSpawns.size)))
- }
+ context.self ! SessionActor.SetZone("tzdrtr", vrDrivingAreaSpawns.toArray.apply(rand.nextInt(vrDrivingAreaSpawns.size)))
case 21 =>
- if (player.Zone.id != "tzdrnc") {
- context.self ! SessionActor.SetZone("tzdrnc", vrDrivingAreaSpawns.toArray.apply(rand.nextInt(vrDrivingAreaSpawns.size)))
- }
+ context.self ! SessionActor.SetZone("tzdrnc", vrDrivingAreaSpawns.toArray.apply(rand.nextInt(vrDrivingAreaSpawns.size)))
case 22 =>
- if (player.Zone.id != "tzdrvs") {
- context.self ! SessionActor.SetZone("tzdrvs", vrDrivingAreaSpawns.toArray.apply(rand.nextInt(vrDrivingAreaSpawns.size)))
- }
- case _ =>
+ context.self ! SessionActor.SetZone("tzdrvs", vrDrivingAreaSpawns.toArray.apply(rand.nextInt(vrDrivingAreaSpawns.size)))
+ case _ =>
log.warn(s"Received TrainingZoneMessage that requests unexpected zone number ${pkt.zone.guid}?")
}
}
@@ -1280,9 +1271,11 @@ class ZoningOperations(
ICS.FindZone(_.id.equals(zoneId), context.self)
))
} else if (player.HasGUID) {
- if (player.IsInVRZone && !zoneId.startsWith("tz")) {
- // reset the player to default gear to prevent them from smuggling things out of VR
- log.info(s"${player.Name} is exiting VR Training, resetting ${player.Sex.possessive} loadout")
+ if (zoneId.startsWith("tz") || player.IsInVRZone) {
+ // reset the players loadout when entering or exiting any VR Training zone
+ // this is to prevent both entering the VR Driving Area with an ExoSuit too heavy to drive,
+ // or smuggling special equipment out of the VR Shooting Range
+ log.info(s"${player.Name} is zoning to or from a VR Training zone, resetting ${player.Sex.possessive} loadout")
val newPlayer = Player.Respawn(player)
DefinitionUtil.applyDefaultLoadout(newPlayer)
session = session.copy(player = newPlayer)
@@ -3176,10 +3169,12 @@ class ZoningOperations(
case _ if player.HasGUID => // player is deconstructing self or instant action
val player_guid = player.GUID
- sendResponse(ObjectDeleteMessage(player_guid, unk1=1))
+ // entering or exiting VR zones uses a fade-out effect for the player instead of the usual green cloud deconstruction effect
+ val effect = if (player.IsInVRZone || zoneId.startsWith("tz")) 2 else 1
+ sendResponse(ObjectDeleteMessage(player_guid, unk1=effect))
continent.AvatarEvents ! AvatarServiceMessage(
continent.id,
- AvatarAction.ObjectDelete(player_guid, player_guid, unk=1)
+ AvatarAction.ObjectDelete(player_guid, player_guid, unk=effect)
)
InGameHistory.SpawnReconstructionActivity(player, toZoneNumber, betterSpawnPoint)
LoadZoneAsPlayerUsing(player, pos, ori, toSide, zoneId)
diff --git a/src/main/scala/net/psforever/actors/zone/ShootingRangeTargetSpawnerActor.scala b/src/main/scala/net/psforever/actors/zone/ShootingRangeTargetSpawnerActor.scala
new file mode 100644
index 000000000..1325caf09
--- /dev/null
+++ b/src/main/scala/net/psforever/actors/zone/ShootingRangeTargetSpawnerActor.scala
@@ -0,0 +1,390 @@
+// Copyright (c) 2026 PSForever
+package net.psforever.actors.zone
+
+import akka.actor.{Actor, Props}
+import net.psforever.objects.{Default, GlobalDefinitions, Tool, Vehicle}
+import net.psforever.objects.avatar.{AvatarBot, AvatarBotActor}
+import net.psforever.objects.guid.{GUIDTask, StraightforwardTask, TaskBundle, TaskWorkflow}
+import net.psforever.objects.zones.Zone
+import net.psforever.services.local.{LocalAction, LocalServiceMessage}
+import net.psforever.services.vehicle.{VehicleAction, VehicleServiceMessage}
+import net.psforever.types.{CharacterSex, CharacterVoice, ExoSuitType, PlanetSideEmpire, PlanetSideGUID, Vector3}
+import net.psforever.util.Config
+
+import scala.collection.mutable.ListBuffer
+import scala.concurrent.ExecutionContext.Implicits.global
+import scala.concurrent.Future
+import scala.concurrent.duration._
+import scala.util.Random
+
+object ShootingRangeTargetSpawner {
+ final case class InfantryTargetReleased(bot: AvatarBot)
+
+ final case class VehicleTargetDeconstructed(vehicle: Vehicle)
+}
+
+class ShootingRangeTargetSpawnerActor(zone: Zone) extends Actor {
+ private[this] val log = org.log4s.getLogger
+
+ private val maleOnlyBotNames = Config.app.game.virtualTraining.maleBotNames
+ private val femaleOnlyBotNames = Config.app.game.virtualTraining.femaleBotNames
+ private val universalBotNames = Config.app.game.virtualTraining.universalBotNames
+
+ private val airVehicleSpawns = List[(Vector3, Float)](
+ (Vector3(526.1094f, 517.84375f, 18.65625f), 295.3125f),
+ (Vector3(507.9297f, 567.4297f, 20.328125f), 182.8125f),
+ (Vector3(533.8516f, 551.28125f, 20.96875f), 278.4375f),
+ (Vector3(504.5390f, 491.9922f, 22.015625f), 64.6875f),
+ (Vector3(464.3281f, 513.5625f, 22.4375f), 348.75f),
+ (Vector3(457.4375f, 546.71094f, 21.90625f), 146.25f),
+ (Vector3(502.7734f, 467.5078f, 31.34375f), 334.6875f),
+ (Vector3(489.2580f, 608.71094f, 28.046875f), 78.75f),
+ (Vector3(570.1562f, 569.4766f, 26.328125f), 230.625f),
+ (Vector3(576.2891f, 487.8594f, 28.71875f), 351.5625f),
+ (Vector3(419.3906f, 572.8984f, 22.296875f), 137.8125f),
+ (Vector3(410.7266f, 493.95312f, 25.578125f), 19.6875f)
+ )
+
+ private val groundVehicleSpawns = List[(Vector3, Float)](
+ (Vector3(501.6562f, 479.1562f, 12.421875f), 351.5625f),
+ (Vector3(463.1484f, 522.72656f, 12.0625f), 59.0625f),
+ (Vector3(510.2812f, 573.9844f, 11.453125f), 295.3125f),
+ (Vector3(543.1330f, 519.0703f, 11.0625f), 188.4375f),
+ (Vector3(547.1406f, 555.09375f, 11.859375f), 315.0f),
+ (Vector3(463.9220f, 570.71094f, 12.15625f), 75.9375f)
+ )
+
+ private val infantrySpawns = List[(Vector3, Float)](
+ (Vector3(499.7969f, 546.6484f, 14.906250f), 191.2500f),
+ (Vector3(507.1172f, 547.7188f, 14.906250f), 205.3125f),
+ (Vector3(497.9688f, 550.0469f, 14.734375f), 199.6875f),
+ (Vector3(519.3438f, 536.0000f, 14.796875f), 275.6250f),
+ (Vector3(481.4609f, 539.5000f, 14.421875f), 135.0000f),
+ (Vector3(522.1094f, 524.4766f, 13.593750f), 306.5625f),
+ (Vector3(481.4453f, 524.0781f, 14.796875f), 84.3750f),
+ (Vector3(499.6875f, 557.7578f, 13.375000f), 180.0000f),
+ (Vector3(483.3984f, 517.5938f, 14.796875f), 67.5000f),
+ (Vector3(487.9531f, 555.8828f, 13.468750f), 157.5000f),
+ (Vector3(527.2109f, 546.3984f, 13.765625f), 250.3125f),
+ (Vector3(472.9609f, 536.8125f, 13.093750f), 95.6250f),
+ (Vector3(476.3984f, 548.1094f, 12.437500f), 126.5625f),
+ (Vector3(503.7188f, 503.8047f, 14.296875f), 357.1875f),
+ (Vector3(475.7344f, 514.3984f, 14.125000f), 56.2500f),
+ (Vector3(492.8750f, 502.5547f, 13.828125f), 5.6250f),
+ (Vector3(511.1172f, 501.3047f, 13.812500f), 345.9375f),
+ (Vector3(498.2422f, 592.5938f, 13.796875f), 180.0000f),
+ (Vector3(448.2500f, 579.2500f, 13.015625f), 120.9375f),
+ (Vector3(417.7109f, 511.1719f, 13.828125f), 70.3125f),
+ (Vector3(589.2812f, 551.2266f, 13.687500f), 250.3125f),
+ (Vector3(505.4062f, 440.4062f, 13.203125f), 19.6875f),
+ (Vector3(540.4297f, 464.6406f, 13.593750f), 306.5625f),
+ (Vector3(549.6016f, 558.6719f, 13.687500f), 250.3125f)
+ )
+
+ private val infantrySpawnsMAX = List[(Vector3, Float)](
+ (Vector3(506.6484f, 544.3984f, 14.937500f), 196.8750f),
+ (Vector3(503.9219f, 559.7109f, 13.375000f), 188.4375f),
+ (Vector3(497.3125f, 510.0312f, 14.640625f), 14.0625f),
+ (Vector3(507.9922f, 511.7656f, 14.625000f), 357.1875f),
+ (Vector3(511.1875f, 558.5625f, 14.640625f), 199.6875f),
+ (Vector3(473.9062f, 517.5156f, 14.265625f), 70.3125f),
+ (Vector3(474.9062f, 541.5234f, 12.765625f), 106.8750f),
+ (Vector3(530.0000f, 513.5469f, 13.046875f), 303.7500f),
+ (Vector3(529.4062f, 538.7891f, 13.390625f), 267.1875f)
+ )
+
+ private val activeInfantryTargets = ListBuffer[AvatarBot]()
+ private val activeVehicleTargets = ListBuffer[(Vehicle, Vector3)]()
+ private var botNamesInUse = List[String]()
+
+ override def preStart() = {
+ if (Config.app.game.virtualTraining.shootingRangeTargetsEnabled) {
+ //delayed to avoid potential GUID registration errors
+ context.system.scheduler.scheduleOnce(
+ 3.seconds,
+ new Runnable() { override def run(): Unit = StartSpawner() }
+ )
+ }
+ }
+
+ override def postStop(): Unit = {
+ activeInfantryTargets.foreach{ target =>
+ target.Actor ! AvatarBot.Release()
+ }
+ activeInfantryTargets.clear()
+ botNamesInUse = List[String]()
+ activeVehicleTargets.foreach{ case (target, pos) =>
+ if (target.Actor != Default.Actor) {
+ target.Actor ! Vehicle.Deconstruct(None)
+ }
+ }
+ activeVehicleTargets.clear()
+ }
+
+ def receive: Receive = {
+ case ShootingRangeTargetSpawner.InfantryTargetReleased(bot) =>
+ RemoveBot(bot)
+
+ case ShootingRangeTargetSpawner.VehicleTargetDeconstructed(vehicle) =>
+ OnVehicleTargetDeconstructed(vehicle)
+
+ case _ => ()
+ }
+
+ private def StartSpawner(): Unit = {
+ val validZone = zone.id match {
+ case "tzshtr" | "tzshnc" | "tzshvs" => true
+ case _ => false
+ }
+ if (!validZone) {
+ log.warn(s"Failed to enable target spawns for zone ${zone.id}; not a valid zone for this behavior")
+ } else {
+ airVehicleSpawns.foreach{case (pos, yaw) => CreateVehicleTarget(pos, yaw, true)}
+ groundVehicleSpawns.foreach{case (pos, yaw) => CreateVehicleTarget(pos, yaw, false)}
+ infantrySpawns.foreach{case (pos, yaw) => CreateInfantryTarget(pos, false)}
+ infantrySpawnsMAX.foreach{case (pos, yaw) => CreateInfantryTarget(pos, true)}
+
+ log.info(s"Enabled target spawns for zone ${zone.id}")
+ }
+ }
+
+ /**
+ * Creates a new infantry target at the specified coordinates.
+ * @param position the position the target will be created at
+ * @param facingYaw the direction the target will be facing
+ * @param isMAX if this target is a MAX unit
+ */
+ def CreateInfantryTarget(position: Vector3, isMAX: Boolean): Unit = {
+ val definition = if (isMAX) GlobalDefinitions.avatar_bot_max_no_weapon else Random.nextInt(3) match {
+ case 0 => GlobalDefinitions.avatar_bot_agile_no_weapon
+ case 1 => GlobalDefinitions.avatar_bot_reinforced_no_weapon
+ case 2 => GlobalDefinitions.avatar_bot_standard_no_weapon
+ }
+ val gender = if (Random.nextBoolean()) CharacterSex.Female else CharacterSex.Male
+ val name = GetRandomBotName(gender)
+ val factionRNG = Random.nextBoolean()
+ val faction = zone.id match {
+ case "tzshtr" => if (factionRNG) PlanetSideEmpire.VS else PlanetSideEmpire.NC
+ case "tzshnc" => if (factionRNG) PlanetSideEmpire.VS else PlanetSideEmpire.TR
+ case "tzshvs" => if (factionRNG) PlanetSideEmpire.NC else PlanetSideEmpire.TR
+ }
+ val head = Random.nextInt(if (gender == CharacterSex.Female) 10 else 11)
+ val voiceRNG = Random.nextInt(4)
+ val voice = voiceRNG match {
+ case 0 => CharacterVoice.Voice1
+ case 1 => CharacterVoice.Voice2
+ case 2 => CharacterVoice.Voice3
+ case 3 => CharacterVoice.Voice4
+ case 4 => CharacterVoice.Voice5
+ }
+
+ val facingYaw = if (isMAX) infantrySpawnsMAX.find(_._1 == position) match {
+ case Some((_, yaw)) => yaw
+ case _ => 0
+ }
+ else infantrySpawns.find(_._1 == position) match {
+ case Some((_, yaw)) => yaw
+ case _ => 0
+ }
+
+ val bot = AvatarBot(name, faction, gender, head, voice, definition)
+ bot.Position = position
+ bot.Orientation = Vector3(0f, 0f, facingYaw)
+ bot.Zone = zone
+ bot.ExoSuit = definition match {
+ case GlobalDefinitions.avatar_bot_agile | GlobalDefinitions.avatar_bot_agile_no_weapon =>
+ ExoSuitType.Agile
+ case GlobalDefinitions.avatar_bot_max | GlobalDefinitions.avatar_bot_max_no_weapon =>
+ ExoSuitType.MAX
+ case GlobalDefinitions.avatar_bot_reinforced | GlobalDefinitions.avatar_bot_reinforced_no_weapon =>
+ ExoSuitType.Reinforced
+ case GlobalDefinitions.avatar_bot_standard | GlobalDefinitions.avatar_bot_standard_no_weapon =>
+ ExoSuitType.Standard
+ case _ =>
+ ExoSuitType.Standard
+ }
+ if (bot.ExoSuit == ExoSuitType.MAX) {
+ val subtype = 1 + Random.nextInt(3)
+ bot.Slot(0).Equipment = Tool(GlobalDefinitions.MAXArms(subtype, faction))
+ bot.DrawnSlot = 0 //max arm up
+ }
+
+ TaskWorkflow.execute(RegisterAndSpawnBot(bot))
+ }
+
+ /**
+ * Gets a random name for a bot and removes the name from the name pool.
+ * @param gender determines if it should pull from the male or female name pools
+ * @return the name as a string
+ */
+ private def GetRandomBotName(gender: CharacterSex): String = {
+ try {
+ gender match {
+ case CharacterSex.Male =>
+ val availableNames = (maleOnlyBotNames ++ universalBotNames).filterNot(n => botNamesInUse.contains(n))
+ if (!availableNames.isEmpty) {
+ val name = availableNames(Random.nextInt(availableNames.size))
+ botNamesInUse = botNamesInUse :+ name
+ name
+ } else {
+ log.warn(s"Male bot name pool in ${zone.id} is empty!")
+ "Bot"
+ }
+ case CharacterSex.Female =>
+ val availableNames = (femaleOnlyBotNames ++ universalBotNames).filterNot(n => botNamesInUse.contains(n))
+ if (!availableNames.isEmpty) {
+ val name = availableNames(Random.nextInt(availableNames.size))
+ botNamesInUse = botNamesInUse :+ name
+ name
+ } else {
+ log.warn(s"Female bot name pool in ${zone.id} is empty!")
+ "Bot"
+ }
+ }
+ } catch {
+ //while the issue that was causing a mutation during iteration exception to be thrown here rarely should hopefully be fixed now,
+ //this is still being put here as a fallback to not block the bot from spawning
+ case ex: Exception =>
+ "Bot"
+ }
+ }
+
+ /**
+ * Registers the `AvatarBot` object and spawns it into the zone.
+ * @param bot the `AvatarBot` object
+ * @return a `TaskBundle` message
+ */
+ private def RegisterAndSpawnBot(bot: AvatarBot): TaskBundle = {
+ import net.psforever.objects.serverobject.PlanetSideServerObject
+ TaskBundle(
+ new StraightforwardTask() {
+ private val localBot = bot
+
+ override def description(): String = s"register a ${localBot.Definition.Name}"
+
+ def action(): Future[Any] = {
+ localBot.Actor = context.actorOf(
+ Props(classOf[AvatarBotActor], localBot, context.self),
+ PlanetSideServerObject.UniqueActorName(localBot)
+ )
+ localBot.Actor ! AvatarBot.Spawn()
+ activeInfantryTargets.addOne(localBot)
+ log.debug(s"Spawned a ${localBot.Faction} bot named ${localBot.Name} in ${zone.id} at ${localBot.Position}")
+ Future(true)
+ }
+ },
+ List(GUIDTask.registerBot(zone.GUID, bot))
+ )
+ }
+
+ /**
+ * Removes the specified bot from the scene and unregisters it.
+ * @param bot the bot to remove
+ */
+ private def RemoveBot(bot: AvatarBot): Boolean = {
+ import net.psforever.services.Service
+ activeInfantryTargets.indexOf(bot) match {
+ case -1 =>
+ log.warn(s"Failed to remove bot with GUID ${bot.GUID} from ${zone.id}'s active targets list! This shouldn't happen... and probably just caused a leak.")
+ false
+ case index =>
+ activeInfantryTargets.remove(index)
+ }
+ zone.LocalEvents ! LocalServiceMessage(
+ zone.id,
+ LocalAction.TriggerEffectLocation(Service.defaultPlayerGUID, "bot_destroyed_effect", bot.Position, bot.Orientation)
+ )
+ //spawn a replacement bot
+ context.system.scheduler.scheduleOnce(
+ 5.seconds,
+ new Runnable() { override def run(): Unit = CreateInfantryTarget(bot.Position, bot.ExoSuit == ExoSuitType.MAX) }
+ )
+ //unregister bot (delay is to prevent ValidObjects from complaining if the bot is getting hit too quickly when it is destroyed)
+ context.system.scheduler.scheduleOnce(
+ 1.seconds,
+ new Runnable() { override def run(): Unit = TaskWorkflow.execute(GUIDTask.unregisterBot(bot.Zone.GUID, bot)) }
+ )
+ //return bot name to name pool
+ botNamesInUse.indexOf(bot.Name) match {
+ case -1 => log.warn(s"Failed to restore bot name `${bot.Name}` to the bot name pool!")
+ case index => botNamesInUse = botNamesInUse.filterNot(n => n == bot.Name)
+ }
+ true
+ }
+
+ /**
+ * Creates a new vehicle target at the specified coordinates.
+ * @param position the position the target will be created at
+ * @param facingYaw the direction the target will be facing
+ * @param airVehicle if this target is an aircraft
+ */
+ private def CreateVehicleTarget(position: Vector3, facingYaw: Float, airVehicle: Boolean): Unit = {
+ val definition = if (airVehicle) Random.nextBoolean() match {
+ case true => GlobalDefinitions.lightgunship
+ case false => GlobalDefinitions.mosquito
+ } else Random.nextInt(4) match {
+ case 0 => GlobalDefinitions.lightning
+ case 1 => GlobalDefinitions.quadassault
+ case 2 => GlobalDefinitions.quadstealth
+ case 3 => GlobalDefinitions.two_man_assault_buggy
+ }
+ val factionRNG = Random.nextBoolean()
+ val faction = zone.id match {
+ case "tzshtr" => if (factionRNG) PlanetSideEmpire.VS else PlanetSideEmpire.NC
+ case "tzshnc" => if (factionRNG) PlanetSideEmpire.VS else PlanetSideEmpire.TR
+ case "tzshvs" => if (factionRNG) PlanetSideEmpire.NC else PlanetSideEmpire.TR
+ }
+
+ val vehicle = Vehicle(definition)
+ vehicle.Position = position
+ vehicle.Orientation = Vector3(0f, 0f, facingYaw)
+ vehicle.Faction = faction
+ TaskWorkflow.execute(RegisterAndSpawnVehicle(vehicle))
+ }
+
+ /**
+ * Registers the `Vehicle` object and spawns it into the zone.
+ * @param vehicle the `Vehicle` object
+ * @return a `TaskBundle` message
+ */
+ private def RegisterAndSpawnVehicle(vehicle: Vehicle): TaskBundle = {
+ TaskBundle(
+ new StraightforwardTask() {
+ private val localVehicle = vehicle
+
+ override def description(): String = s"register a ${localVehicle.Definition.Name}"
+
+ def action(): Future[Any] = {
+ zone.Transport ! Zone.Vehicle.Spawn(localVehicle)
+ zone.VehicleEvents ! VehicleServiceMessage(
+ zone.id,
+ VehicleAction.LoadVehicle(
+ PlanetSideGUID(0),
+ localVehicle,
+ localVehicle.Definition.ObjectId,
+ localVehicle.GUID,
+ localVehicle.Definition.Packet.ConstructorData(localVehicle).get
+ )
+ )
+ activeVehicleTargets.addOne((localVehicle, localVehicle.Position))
+ log.debug(s"Spawned a ${localVehicle.Faction} ${localVehicle.Definition.Name} in ${zone.id} at ${localVehicle.Position}")
+ Future(true)
+ }
+ },
+ List(GUIDTask.registerVehicle(zone.GUID, vehicle))
+ )
+ }
+
+ private def OnVehicleTargetDeconstructed(vehicle: Vehicle): Unit = {
+ activeVehicleTargets.find(_._1 == vehicle) match {
+ case Some((target, pos)) =>
+ val index = activeVehicleTargets.indexOf((target, pos))
+ activeVehicleTargets.remove(index)
+ context.system.scheduler.scheduleOnce(
+ 5.seconds,
+ new Runnable() { override def run(): Unit = CreateVehicleTarget(pos, vehicle.Orientation.z, GlobalDefinitions.isFlightVehicle(vehicle.Definition)) }
+ )
+ case None =>
+ }
+ }
+}
diff --git a/src/main/scala/net/psforever/objects/GlobalDefinitions.scala b/src/main/scala/net/psforever/objects/GlobalDefinitions.scala
index b9b99181a..27ebd76fc 100644
--- a/src/main/scala/net/psforever/objects/GlobalDefinitions.scala
+++ b/src/main/scala/net/psforever/objects/GlobalDefinitions.scala
@@ -47,6 +47,96 @@ object GlobalDefinitions {
avatar.collision.z = CollisionZData(Array((0.1f, 0), (5f, 1), (10f, 3), (20f, 5), (35f, 7), (50f, 10), (75f, 40), (100f, 100))) //not in the ADB
avatar.maxForwardSpeed = 27f //not in the ADB; running speed
+ val avatar_bot = new AvatarBotDefinition(122)
+ avatar_bot.MaxHealth = 100
+ avatar_bot.Damageable = true
+ avatar_bot.DrownAtMaxDepth = true
+ avatar_bot.MaxDepth = 1.609375f //Male, standing, not MAX
+ avatar_bot.UnderwaterLifespan(suffocation = 60000L, recovery = 10000L)
+ avatar_bot.collision.xy = CollisionXYData(Array((0.1f, 0), (0.2f, 5), (0.50f, 15), (0.75f, 20), (1f, 30))) //not in the ADB
+ avatar_bot.collision.z = CollisionZData(Array((0.1f, 0), (5f, 1), (10f, 3), (20f, 5), (35f, 7), (50f, 10), (75f, 40), (100f, 100))) //not in the ADB
+ avatar_bot.maxForwardSpeed = 27f //not in the ADB; running speed
+
+ val avatar_bot_agile = new AvatarBotDefinition(123)
+ avatar_bot_agile.MaxHealth = 100
+ avatar_bot_agile.Damageable = true
+ avatar_bot_agile.DrownAtMaxDepth = true
+ avatar_bot_agile.MaxDepth = 1.609375f //Male, standing, not MAX
+ avatar_bot_agile.UnderwaterLifespan(suffocation = 60000L, recovery = 10000L)
+ avatar_bot_agile.collision.xy = CollisionXYData(Array((0.1f, 0), (0.2f, 5), (0.50f, 15), (0.75f, 20), (1f, 30))) //not in the ADB
+ avatar_bot_agile.collision.z = CollisionZData(Array((0.1f, 0), (5f, 1), (10f, 3), (20f, 5), (35f, 7), (50f, 10), (75f, 40), (100f, 100))) //not in the ADB
+ avatar_bot_agile.maxForwardSpeed = 27f //not in the ADB; running speed
+
+ val avatar_bot_agile_no_weapon = new AvatarBotDefinition(124)
+ avatar_bot_agile_no_weapon.MaxHealth = 100
+ avatar_bot_agile_no_weapon.Damageable = true
+ avatar_bot_agile_no_weapon.DrownAtMaxDepth = true
+ avatar_bot_agile_no_weapon.MaxDepth = 1.609375f //Male, standing, not MAX
+ avatar_bot_agile_no_weapon.UnderwaterLifespan(suffocation = 60000L, recovery = 10000L)
+ avatar_bot_agile_no_weapon.collision.xy = CollisionXYData(Array((0.1f, 0), (0.2f, 5), (0.50f, 15), (0.75f, 20), (1f, 30))) //not in the ADB
+ avatar_bot_agile_no_weapon.collision.z = CollisionZData(Array((0.1f, 0), (5f, 1), (10f, 3), (20f, 5), (35f, 7), (50f, 10), (75f, 40), (100f, 100))) //not in the ADB
+ avatar_bot_agile_no_weapon.maxForwardSpeed = 27f //not in the ADB; running speed
+
+ val avatar_bot_max = new AvatarBotDefinition(125)
+ avatar_bot_max.MaxHealth = 100
+ avatar_bot_max.Damageable = true
+ avatar_bot_max.DrownAtMaxDepth = true
+ avatar_bot_max.MaxDepth = 1.609375f //Male, standing, not MAX
+ avatar_bot_max.UnderwaterLifespan(suffocation = 60000L, recovery = 10000L)
+ avatar_bot_max.collision.xy = CollisionXYData(Array((0.1f, 0), (0.2f, 5), (0.50f, 15), (0.75f, 20), (1f, 30))) //not in the ADB
+ avatar_bot_max.collision.z = CollisionZData(Array((0.1f, 0), (5f, 1), (10f, 3), (20f, 5), (35f, 7), (50f, 10), (75f, 40), (100f, 100))) //not in the ADB
+ avatar_bot_max.maxForwardSpeed = 27f //not in the ADB; running speed
+
+ val avatar_bot_max_no_weapon = new AvatarBotDefinition(126)
+ avatar_bot_max_no_weapon.MaxHealth = 100
+ avatar_bot_max_no_weapon.Damageable = true
+ avatar_bot_max_no_weapon.DrownAtMaxDepth = true
+ avatar_bot_max_no_weapon.MaxDepth = 1.609375f //Male, standing, not MAX
+ avatar_bot_max_no_weapon.UnderwaterLifespan(suffocation = 60000L, recovery = 10000L)
+ avatar_bot_max_no_weapon.collision.xy = CollisionXYData(Array((0.1f, 0), (0.2f, 5), (0.50f, 15), (0.75f, 20), (1f, 30))) //not in the ADB
+ avatar_bot_max_no_weapon.collision.z = CollisionZData(Array((0.1f, 0), (5f, 1), (10f, 3), (20f, 5), (35f, 7), (50f, 10), (75f, 40), (100f, 100))) //not in the ADB
+ avatar_bot_max_no_weapon.maxForwardSpeed = 27f //not in the ADB; running speed
+
+ val avatar_bot_reinforced = new AvatarBotDefinition(127)
+ avatar_bot_reinforced.MaxHealth = 100
+ avatar_bot_reinforced.Damageable = true
+ avatar_bot_reinforced.DrownAtMaxDepth = true
+ avatar_bot_reinforced.MaxDepth = 1.609375f //Male, standing, not MAX
+ avatar_bot_reinforced.UnderwaterLifespan(suffocation = 60000L, recovery = 10000L)
+ avatar_bot_reinforced.collision.xy = CollisionXYData(Array((0.1f, 0), (0.2f, 5), (0.50f, 15), (0.75f, 20), (1f, 30))) //not in the ADB
+ avatar_bot_reinforced.collision.z = CollisionZData(Array((0.1f, 0), (5f, 1), (10f, 3), (20f, 5), (35f, 7), (50f, 10), (75f, 40), (100f, 100))) //not in the ADB
+ avatar_bot_reinforced.maxForwardSpeed = 27f //not in the ADB; running speed
+
+ val avatar_bot_reinforced_no_weapon = new AvatarBotDefinition(128)
+ avatar_bot_reinforced_no_weapon.MaxHealth = 100
+ avatar_bot_reinforced_no_weapon.Damageable = true
+ avatar_bot_reinforced_no_weapon.DrownAtMaxDepth = true
+ avatar_bot_reinforced_no_weapon.MaxDepth = 1.609375f //Male, standing, not MAX
+ avatar_bot_reinforced_no_weapon.UnderwaterLifespan(suffocation = 60000L, recovery = 10000L)
+ avatar_bot_reinforced_no_weapon.collision.xy = CollisionXYData(Array((0.1f, 0), (0.2f, 5), (0.50f, 15), (0.75f, 20), (1f, 30))) //not in the ADB
+ avatar_bot_reinforced_no_weapon.collision.z = CollisionZData(Array((0.1f, 0), (5f, 1), (10f, 3), (20f, 5), (35f, 7), (50f, 10), (75f, 40), (100f, 100))) //not in the ADB
+ avatar_bot_reinforced_no_weapon.maxForwardSpeed = 27f //not in the ADB; running speed
+
+ val avatar_bot_standard = new AvatarBotDefinition(129)
+ avatar_bot_standard.MaxHealth = 100
+ avatar_bot_standard.Damageable = true
+ avatar_bot_standard.DrownAtMaxDepth = true
+ avatar_bot_standard.MaxDepth = 1.609375f //Male, standing, not MAX
+ avatar_bot_standard.UnderwaterLifespan(suffocation = 60000L, recovery = 10000L)
+ avatar_bot_standard.collision.xy = CollisionXYData(Array((0.1f, 0), (0.2f, 5), (0.50f, 15), (0.75f, 20), (1f, 30))) //not in the ADB
+ avatar_bot_standard.collision.z = CollisionZData(Array((0.1f, 0), (5f, 1), (10f, 3), (20f, 5), (35f, 7), (50f, 10), (75f, 40), (100f, 100))) //not in the ADB
+ avatar_bot_standard.maxForwardSpeed = 27f //not in the ADB; running speed
+
+ val avatar_bot_standard_no_weapon = new AvatarBotDefinition(130)
+ avatar_bot_standard_no_weapon.MaxHealth = 100
+ avatar_bot_standard_no_weapon.Damageable = true
+ avatar_bot_standard_no_weapon.DrownAtMaxDepth = true
+ avatar_bot_standard_no_weapon.MaxDepth = 1.609375f //Male, standing, not MAX
+ avatar_bot_standard_no_weapon.UnderwaterLifespan(suffocation = 60000L, recovery = 10000L)
+ avatar_bot_standard_no_weapon.collision.xy = CollisionXYData(Array((0.1f, 0), (0.2f, 5), (0.50f, 15), (0.75f, 20), (1f, 30))) //not in the ADB
+ avatar_bot_standard_no_weapon.collision.z = CollisionZData(Array((0.1f, 0), (5f, 1), (10f, 3), (20f, 5), (35f, 7), (50f, 10), (75f, 40), (100f, 100))) //not in the ADB
+ avatar_bot_standard_no_weapon.maxForwardSpeed = 27f //not in the ADB; running speed
+
/*
exo-suits
*/
diff --git a/src/main/scala/net/psforever/objects/avatar/AvatarBot.scala b/src/main/scala/net/psforever/objects/avatar/AvatarBot.scala
new file mode 100644
index 000000000..7ff077f5b
--- /dev/null
+++ b/src/main/scala/net/psforever/objects/avatar/AvatarBot.scala
@@ -0,0 +1,284 @@
+// Copyright (c) 2026 PSForever
+package net.psforever.objects.avatar
+
+import net.psforever.objects.avatar.interaction.{InteractWithForceDomeProtection, TriggerOnPlayerRule, WithEntrance, WithGantry, WithLava, WithWater}
+import net.psforever.objects.{GlobalDefinitions, Player, OffhandEquipmentSlot}
+import net.psforever.objects.ballistics.InteractWithRadiationClouds
+import net.psforever.objects.ce.{InteractWithMines, InteractWithTurrets}
+import net.psforever.objects.definition.{AvatarBotDefinition, ExoSuitDefinition}
+import net.psforever.objects.equipment.{EquipmentSize, EquipmentSlot, JammableUnit}
+import net.psforever.objects.inventory.{Container, GridInventory, InventoryItem}
+import net.psforever.objects.serverobject.{PlanetSideServerObject, environment}
+import net.psforever.objects.serverobject.affinity.FactionAffinity
+import net.psforever.objects.serverobject.aura.AuraContainer
+import net.psforever.objects.serverobject.environment.interaction.common.{WithDeath, WithMovementTrigger}
+import net.psforever.objects.serverobject.interior.InteriorAwareFromInteraction
+import net.psforever.objects.vital.Vitality
+import net.psforever.objects.vital.damage.DamageProfile
+import net.psforever.objects.vital.interaction.DamageInteraction
+import net.psforever.objects.vital.resistance.ResistanceProfile
+import net.psforever.objects.vital.resolution.DamageResistanceModel
+import net.psforever.objects.zones.blockmap.BlockMapEntity
+import net.psforever.objects.zones.interaction.InteractsWithZone
+import net.psforever.objects.zones.ZoneAware
+import net.psforever.packet.game.objectcreate.BasicCharacterData
+import net.psforever.types._
+
+/**
+ * A stripped down combination of the `Avatar` and `Player` classes.
+ * This acts as a base class for NPC avatars, and is used for VR Shooting Range infantry targets.
+ * @see `Avatar`
+ * @see `Player`
+ */
+case class AvatarBot(
+ basic: BasicCharacterData,
+ definition: AvatarBotDefinition,
+ bep: Long = 0,
+ cep: Long = 0,
+ certifications: Set[Certification] = Set(),
+ implants: Seq[Option[Implant]] = Seq(None, None, None),
+ decoration: ProgressDecoration = ProgressDecoration()
+) extends PlanetSideServerObject
+ with BlockMapEntity
+ with InteractsWithZone
+ with FactionAffinity
+ with Vitality
+ with ResistanceProfile
+ with Container
+ with JammableUnit
+ with ZoneAware
+ with InteriorAwareFromInteraction
+ with AuraContainer {
+ interaction(new InteractWithForceDomeProtection())
+ interaction(environment.interaction.InteractWithEnvironment(Seq(
+ new WithEntrance(),
+ new WithWater(Name),
+ new WithLava(),
+ new WithDeath(),
+ new WithGantry(Name),
+ new WithMovementTrigger()
+ )))
+ interaction(new InteractWithMines(range = 10, TriggerOnPlayerRule))
+ interaction(new InteractWithTurrets())
+ interaction(new InteractWithRadiationClouds(range = 10f, None))
+
+ val br: BattleRank = BattleRank.withExperience(bep)
+ val cr: CommandRank = CommandRank.withExperience(cep)
+
+ private var armor: Int = 0
+
+ private var exosuit: ExoSuitDefinition = GlobalDefinitions.Standard
+ private val freeHand: EquipmentSlot = new OffhandEquipmentSlot(EquipmentSize.Inventory)
+ private val holsters: Array[EquipmentSlot] = Array.fill[EquipmentSlot](5)(new EquipmentSlot)
+ private val inventory: GridInventory = GridInventory()
+ private var drawnSlot: Int = Player.HandsDownSlot
+ private var lastDrawnSlot: Int = Player.HandsDownSlot
+
+ private var facingYawUpper: Float = 0f
+ private var crouching: Boolean = false
+ private var jumping: Boolean = false
+ private var cloaked: Boolean = false
+
+ /** The maximum stamina amount */
+ val maxStamina: Int = 100
+
+ var fatigued: Boolean = false
+ var stamina: Int = 100
+
+ //init
+ Health = 0 //bot health is artificially managed as a part of their lifecycle; start entity as dead
+ Destroyed = true //see isAlive
+ AvatarBot.SuitSetup(this, exosuit)
+
+ def Definition: AvatarBotDefinition = definition
+
+ def Name: String = basic.name
+
+ def Faction: PlanetSideEmpire.Value = basic.faction
+
+ def Sex: CharacterSex = basic.sex
+
+ def Head: Int = basic.head
+
+ def Voice: CharacterVoice.Value = basic.voice
+
+ def isAlive: Boolean = !Destroyed
+
+ def Spawn(): Boolean = {
+ if (!isAlive) {
+ Destroyed = false
+ Health = Definition.DefaultHealth
+ Armor = MaxArmor
+ }
+ isAlive
+ }
+
+ def Die: Boolean = {
+ Destroyed = true
+ Health = 0
+ false
+ }
+
+ def Armor: Int = armor
+
+ def Armor_=(assignArmor: Int): Int = {
+ armor = math.min(math.max(0, assignArmor), MaxArmor)
+ Armor
+ }
+
+ def MaxArmor: Int = exosuit.MaxArmor
+
+ override def Slot(slot: Int): EquipmentSlot = {
+ if (inventory.Offset <= slot && slot <= inventory.LastIndex) {
+ inventory.Slot(slot)
+ } else if (slot > -1 && slot < 5) {
+ holsters(slot)
+ } else if (slot == 5) {
+ OffhandEquipmentSlot.BlockedSlot
+ } else if (slot == Player.FreeHandSlot) {
+ freeHand
+ } else {
+ OffhandEquipmentSlot.BlockedSlot
+ }
+ }
+
+ def VisibleSlots: Set[Int] =
+ if (exosuit.SuitType == ExoSuitType.MAX) {
+ Set(0)
+ } else {
+ (0 to 4).filterNot(index => holsters(index).Size == EquipmentSize.Blocked).toSet
+ }
+
+ def Holsters(): Array[EquipmentSlot] = holsters
+
+ /**
+ * Transform the holster equipment slots
+ * into a list of the kind of item wrapper found in an inventory.
+ * @see `GridInventory`
+ * @see `InventoryItem`
+ * @return a list of items that would be found in a proper inventory
+ */
+ def HolsterItems(): List[InventoryItem] = holsters
+ .zipWithIndex
+ .collect {
+ case (slot: EquipmentSlot, index: Int) =>
+ slot.Equipment match {
+ case Some(item) => Some(InventoryItem(item, index))
+ case None => None
+ }
+ }.flatten.toList
+
+ def Inventory: GridInventory = inventory
+
+ def DrawnSlot: Int = drawnSlot
+
+ def DrawnSlot_=(slot: Int): Int = {
+ if (slot != drawnSlot) {
+ if (slot == Player.HandsDownSlot) {
+ drawnSlot = slot
+ } else if (VisibleSlots.contains(slot) && holsters(slot).Equipment.isDefined) {
+ drawnSlot = slot
+ lastDrawnSlot = slot
+ }
+ }
+ DrawnSlot
+ }
+
+ def LastDrawnSlot: Int = lastDrawnSlot
+
+ def ExoSuit: ExoSuitType.Value = exosuit.SuitType
+ def ExoSuitDef: ExoSuitDefinition = exosuit
+
+ def ExoSuit_=(suit: ExoSuitType.Value): Unit = {
+ val eSuit = ExoSuitDefinition.Select(suit, Faction)
+ exosuit = eSuit
+ AvatarBot.SuitSetup(this, eSuit)
+ }
+
+ def Subtract: DamageProfile = exosuit.Subtract
+
+ def ResistanceDirectHit: Int = exosuit.ResistanceDirectHit
+
+ def ResistanceSplash: Int = exosuit.ResistanceSplash
+
+ def ResistanceAggravated: Int = exosuit.ResistanceAggravated
+
+ def RadiationShielding: Float = exosuit.RadiationShielding
+
+ def FacingYawUpper: Float = facingYawUpper
+
+ def FacingYawUpper_=(facing: Float): Float = {
+ facingYawUpper = facing
+ FacingYawUpper
+ }
+
+ def Crouching: Boolean = crouching
+
+ def Crouching_=(crouched: Boolean): Boolean = {
+ crouching = crouched
+ Crouching
+ }
+
+ def Jumping: Boolean = jumping
+
+ def Jumping_=(jumped: Boolean): Boolean = {
+ jumping = jumped
+ Jumping
+ }
+
+ def Cloaked: Boolean = cloaked
+
+ def Cloaked_=(isCloaked: Boolean): Boolean = {
+ cloaked = isCloaked
+ Cloaked
+ }
+
+ override def CanDamage: Boolean = {
+ isAlive && super.CanDamage
+ }
+
+ def DamageModel: DamageResistanceModel = exosuit.asInstanceOf[DamageResistanceModel]
+
+ /** Return true if the stamina is at the maximum amount */
+ def staminaFull: Boolean = {
+ stamina == maxStamina
+ }
+
+ override def toString: String = {
+ val guid = if (HasGUID) {
+ s" $Continent-${GUID.guid}"
+ } else {
+ ""
+ }
+ s"${basic.name}$guid ${basic.faction} H: $Health/$MaxHealth A: $Armor/$MaxArmor"
+ }
+}
+
+object AvatarBot {
+ final case class Die(reason: Option[DamageInteraction])
+
+ final case class Release()
+
+ final case class Spawn()
+
+ object Die {
+ def apply(): Die = Die(None)
+
+ def apply(reason: DamageInteraction): Die = {
+ Die(Some(reason))
+ }
+ }
+
+ def apply(name: String, faction: PlanetSideEmpire.Value, sex: CharacterSex, head: Int, voice: CharacterVoice.Value, definition: AvatarBotDefinition): AvatarBot = {
+ AvatarBot(BasicCharacterData(name, faction, sex, head, voice), definition)
+ }
+
+ private def SuitSetup(bot: AvatarBot, eSuit: ExoSuitDefinition): Unit = {
+ //inventory
+ bot.Inventory.Clear()
+ bot.Inventory.Resize(eSuit.InventoryScale.Width, eSuit.InventoryScale.Height)
+ bot.Inventory.Offset = eSuit.InventoryOffset
+ //holsters
+ (0 until 5).foreach { index => bot.Slot(index).Size = eSuit.Holster(index) }
+ }
+}
diff --git a/src/main/scala/net/psforever/objects/avatar/AvatarBotActor.scala b/src/main/scala/net/psforever/objects/avatar/AvatarBotActor.scala
new file mode 100644
index 000000000..486289d66
--- /dev/null
+++ b/src/main/scala/net/psforever/objects/avatar/AvatarBotActor.scala
@@ -0,0 +1,546 @@
+// Copyright (c) 2026 PSForever
+package net.psforever.objects.avatar
+
+import akka.actor.{Actor, ActorRef}
+import net.psforever.actors.zone.ShootingRangeTargetSpawner
+import net.psforever.objects.{GlobalDefinitions, Tool}
+import net.psforever.objects.avatar.AvatarBot
+import net.psforever.objects.equipment._
+import net.psforever.objects.serverobject.aura.{Aura, AuraEffectBehavior}
+import net.psforever.objects.serverobject.CommonMessages
+import net.psforever.objects.serverobject.damage.Damageable.Target
+import net.psforever.objects.serverobject.damage.{AggravatedBehavior, Damageable, DamageableEntity}
+import net.psforever.objects.vital.resolution.ResolutionCalculations.Output
+import net.psforever.objects.zones._
+import net.psforever.packet.game._
+import net.psforever.types._
+import net.psforever.services.Service
+import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage}
+import net.psforever.services.local.{LocalAction, LocalServiceMessage}
+import net.psforever.objects.serverobject.environment.interaction.RespondsToZoneEnvironment
+import net.psforever.objects.serverobject.repair.Repairable
+import net.psforever.objects.sourcing.PlayerSource
+import net.psforever.objects.vital.{HealFromEquipment, RepairFromEquipment}
+import net.psforever.objects.vital.etc.SuicideReason
+import net.psforever.objects.vital.interaction.{DamageInteraction, DamageResult}
+
+import java.util.concurrent.{Executors, TimeUnit}
+
+import scala.concurrent.ExecutionContext.Implicits.global
+import scala.concurrent.duration._
+import scala.util.Random
+
+class AvatarBotActor(bot: AvatarBot, spawnerActor: ActorRef)
+ extends Actor
+ with JammableBehavior
+ with Damageable
+ with AggravatedBehavior
+ with AuraEffectBehavior
+ with RespondsToZoneEnvironment {
+ def JammableObject: AvatarBot = bot
+
+ def DamageableObject: AvatarBot = bot
+
+ def ContainerObject: AvatarBot = bot
+
+ def AggravatedObject: AvatarBot = bot
+
+ def AuraTargetObject: AvatarBot = bot
+ ApplicableEffect(Aura.Plasma)
+ ApplicableEffect(Aura.Napalm)
+ ApplicableEffect(Aura.Comet)
+ ApplicableEffect(Aura.Fire)
+
+ def InteractiveObject: AvatarBot = bot
+
+ private[this] val log = org.log4s.getLogger(bot.Name)
+ private[this] val damageLog = org.log4s.getLogger(Damageable.LogChannel)
+ private val scheduler = Executors.newScheduledThreadPool(2)
+ /** suffocating, or regaining breath? */
+ var submergedCondition: Option[OxygenState] = None
+ private var canEmote = false
+ private var canRotate = false
+
+ override def postStop(): Unit = {
+ EndAllEffects()
+ EndAllAggravation()
+ respondToEnvironmentPostStop()
+ scheduler.shutdown()
+ }
+
+ def receive: Receive = Enabled
+
+ def Enabled: Receive =
+ jammableBehavior
+ .orElse(takesDamage)
+ .orElse(aggravatedBehavior)
+ .orElse(auraBehavior)
+ .orElse(environmentBehavior)
+ .orElse {
+ case AvatarBot.Spawn() =>
+ spawn()
+
+ case AvatarBot.Die(Some(reason)) =>
+ dieWithReason(reason)
+
+ case AvatarBot.Die(None) =>
+ suicide()
+
+ case AvatarBot.Release() =>
+ release()
+
+ case CommonMessages.Use(user, Some(item: Tool))
+ if item.Definition == GlobalDefinitions.medicalapplicator && bot.isAlive =>
+ //heal
+ val originalHealth = bot.Health
+ val definition = bot.Definition
+ if (
+ bot.MaxHealth > 0 && originalHealth < bot.MaxHealth &&
+ user.Faction == bot.Faction &&
+ item.Magazine > 0 &&
+ Vector3.Distance(user.Position, bot.Position) < definition.RepairDistance
+ ) {
+ val zone = bot.Zone
+ val events = zone.AvatarEvents
+ val uname = user.Name
+ val guid = bot.GUID
+ if (!(bot.isMoving || user.isMoving)) { //only allow stationary heals
+ val newHealth = bot.Health = originalHealth + 10
+ val magazine = item.Discharge()
+ events ! AvatarServiceMessage(
+ uname,
+ AvatarAction.SendResponse(
+ Service.defaultPlayerGUID,
+ InventoryStateMessage(item.AmmoSlot.Box.GUID, item.GUID, magazine.toLong)
+ )
+ )
+ events ! AvatarServiceMessage(zone.id, AvatarAction.PlanetsideAttributeToAll(guid, 0, newHealth))
+ bot.LogActivity(
+ HealFromEquipment(
+ PlayerSource(user),
+ GlobalDefinitions.medicalapplicator,
+ newHealth - originalHealth
+ )
+ )
+ }
+ //progress bar remains visible for all heal attempts
+ events ! AvatarServiceMessage(
+ uname,
+ AvatarAction.SendResponse(
+ Service.defaultPlayerGUID,
+ RepairMessage(guid, bot.Health * 100 / definition.MaxHealth)
+ )
+ )
+ }
+
+ case CommonMessages.Use(user, Some(item: Tool)) if item.Definition == GlobalDefinitions.bank =>
+ val originalArmor = bot.Armor
+ val definition = bot.Definition
+ if (
+ bot.MaxArmor > 0 && originalArmor < bot.MaxArmor &&
+ user.Faction == bot.Faction &&
+ item.AmmoType == Ammo.armor_canister && item.Magazine > 0 &&
+ Vector3.Distance(user.Position, bot.Position) < definition.RepairDistance
+ ) {
+ val zone = bot.Zone
+ val events = zone.AvatarEvents
+ val uname = user.Name
+ val guid = bot.GUID
+ if (!(bot.isMoving || user.isMoving)) { //only allow stationary repairs
+ val newArmor = bot.Armor =
+ originalArmor + Repairable.applyLevelModifier(user, item, RepairToolValue(item)).toInt + definition.RepairMod
+ val magazine = item.Discharge()
+ events ! AvatarServiceMessage(
+ uname,
+ AvatarAction.SendResponse(
+ Service.defaultPlayerGUID,
+ InventoryStateMessage(item.AmmoSlot.Box.GUID, item.GUID, magazine.toLong)
+ )
+ )
+ events ! AvatarServiceMessage(zone.id, AvatarAction.PlanetsideAttributeToAll(guid, 4, bot.Armor))
+ bot.LogActivity(
+ RepairFromEquipment(
+ PlayerSource(user),
+ GlobalDefinitions.bank,
+ newArmor - originalArmor
+ )
+ )
+ }
+ //progress bar remains visible for all repair attempts
+ events ! AvatarServiceMessage(
+ uname,
+ AvatarAction
+ .SendResponse(Service.defaultPlayerGUID, RepairMessage(guid, bot.Armor * 100 / bot.MaxArmor))
+ )
+ }
+
+ case _ => ;
+ }
+
+ def Disabled: Receive = {
+ case AvatarBot.Spawn() =>
+ spawn()
+
+ case AvatarBot.Die(Some(reason)) =>
+ dieWithReason(reason)
+
+ case AvatarBot.Die(None) =>
+ suicide()
+
+ case AvatarBot.Release() =>
+ release()
+
+ case _ => ;
+ }
+
+ override protected def PerformDamage(
+ target: Target,
+ applyDamageTo: Output
+ ): Unit = {
+ if (bot.isAlive) {
+ val originalHealth = bot.Health
+ val originalArmor = bot.Armor
+ val originalStamina = bot.stamina
+ val cause = applyDamageTo(bot)
+ val health = bot.Health
+ val armor = bot.Armor
+ val stamina = bot.stamina
+ val damageToHealth = originalHealth - health
+ val damageToArmor = originalArmor - armor
+ val damageToStamina = originalStamina - stamina
+ HandleDamage(bot, cause, damageToHealth, damageToArmor, damageToStamina)
+ if (damageToHealth > 0 || damageToArmor > 0 || damageToStamina > 0) {
+ damageLog.info(
+ s"${bot.Name}-infantry: BEFORE=$originalHealth/$originalArmor/$originalStamina, AFTER=$health/$armor/$stamina, CHANGE=$damageToHealth/$damageToArmor/$damageToStamina"
+ )
+ }
+ }
+ }
+
+ /**
+ * na
+ * @param target na
+ */
+ def HandleDamage(
+ target: AvatarBot,
+ cause: DamageResult,
+ damageToHealth: Int,
+ damageToArmor: Int,
+ damageToStamina: Int
+ ): Unit = {
+ //always do armor update
+ if (damageToArmor > 0) {
+ val zone = target.Zone
+ zone.AvatarEvents ! AvatarServiceMessage(
+ zone.id,
+ AvatarAction.PlanetsideAttributeToAll(target.GUID, 4, target.Armor)
+ )
+ }
+ //choose
+ if (target.Health > 0) {
+ //alive, take damage/update
+ DamageAwareness(target, cause, damageToHealth, damageToArmor, damageToStamina)
+ } else {
+ //ded
+ DestructionAwareness(target, cause)
+ }
+ }
+
+ def DamageAwareness(
+ target: AvatarBot,
+ cause: DamageResult,
+ damageToHealth: Int,
+ damageToArmor: Int,
+ damageToStamina: Int
+ ): Unit = {
+ val targetGUID = target.GUID
+ val zone = target.Zone
+ val zoneId = zone.id
+ val events = zone.AvatarEvents
+ val health = target.Health
+ var announceConfrontation = damageToArmor > 0
+ //special effects
+ if (Damageable.CanJammer(target, cause.interaction)) {
+ TryJammerEffectActivate(target, cause)
+ }
+ val aggravated: Boolean = TryAggravationEffectActivate(cause) match {
+ case Some(aggravation) =>
+ StartAuraEffect(aggravation.effect_type, aggravation.timing.duration)
+ announceConfrontation = true //useful if initial damage (to anything) is zero
+ //initial damage for aggravation, but never treat as "aggravated"
+ false
+ case _ =>
+ cause.interaction.cause.source.Aggravated.nonEmpty
+ }
+ //log historical event (always)
+ target.LogActivity(cause)
+ //stat changes
+ if (damageToStamina > 0) {
+ target.stamina = math.max(0, target.stamina - damageToStamina)
+ announceConfrontation = true //TODO should we?
+ }
+ if (damageToHealth > 0) {
+ events ! AvatarServiceMessage(zoneId, AvatarAction.PlanetsideAttributeToAll(targetGUID, 0, health))
+ announceConfrontation = true
+ }
+ val countableDamage = damageToHealth + damageToArmor
+ if(announceConfrontation) {
+ if (aggravated) {
+ events ! AvatarServiceMessage(
+ zoneId,
+ AvatarAction.SendResponse(targetGUID, AggravatedDamageMessage(targetGUID, countableDamage))
+ )
+ } else {
+ //activity on map
+ zone.Activity ! Zone.HotSpot.Activity(cause)
+ }
+ }
+ }
+
+ /**
+ * The bot has lost all his vitality and must be killed.
+ *
+ * Shift directly into a state of being dead on the client by setting health to zero points,
+ * whereupon the bot will perform a dramatic death animation.
+ * Stamina is also set to zero points.
+ * Do not move or completely destroy the `AvatarBot` object as its coordinates of death will be important.
+ *
+ * @param target na
+ * @param cause na
+ */
+ def DestructionAwareness(target: AvatarBot, cause: DamageResult): Unit = {
+ val bot_guid = target.GUID
+ val pos = target.Position
+ val zone = target.Zone
+ val events = zone.AvatarEvents
+ val nameChannel = target.Name
+ val zoneChannel = zone.id
+ target.Die
+ //aura effects cancel
+ EndAllEffects()
+ //aggravation cancel
+ EndAllAggravation()
+ //unjam
+ CancelJammeredSound(target)
+ super.CancelJammeredStatus(target)
+ //no stamina
+ target.stamina = 0
+
+ //log historical event
+ target.LogActivity(cause)
+ //log message
+ cause.adversarial match {
+ case Some(a) =>
+ damageLog.info(s"${a.defender.Name} was killed by ${a.attacker.Name}")
+ events ! AvatarServiceMessage(
+ zoneChannel,
+ AvatarAction.DestroyDisplay(a.attacker, a.defender, a.implement)
+ )
+ case _ =>
+ damageLog.info(s"${bot.Name} killed ${bot.Sex.pronounObject}self")
+ events ! AvatarServiceMessage(zoneChannel, AvatarAction.DestroyDisplay(cause.interaction.target, cause.interaction.target, 0))
+ }
+
+ events ! AvatarServiceMessage(nameChannel, AvatarAction.Killed(bot_guid, cause, None)) //align client interface fields with state
+ events ! AvatarServiceMessage(zoneChannel, AvatarAction.PlanetsideAttributeToAll(bot_guid, 0, 0)) //health
+ val attribute = DamageableEntity.attributionTo(cause, target.Zone, bot_guid)
+ events ! AvatarServiceMessage(
+ nameChannel,
+ AvatarAction.SendResponse(
+ bot_guid,
+ DestroyMessage(bot_guid, attribute, bot_guid, pos)
+ ) //how many players get this message?
+ )
+
+ context.self ! AvatarBot.Release()
+ }
+
+ def suicide() : Unit = {
+ if (bot.Health > 0 || bot.isAlive) {
+ PerformDamage(
+ bot,
+ DamageInteraction(
+ PlayerSource(bot),
+ SuicideReason(),
+ bot.Position
+ ).calculate()
+ )
+ }
+ }
+
+ private def spawn(): Unit = {
+ bot.Spawn()
+ bot.Zone.Population ! Zone.Bots.Spawn(bot)
+ canEmote = true
+ canRotate = true
+ scheduler.scheduleAtFixedRate(new Runnable() { override def run(): Unit = tickLogic() }, 0, 250, TimeUnit.MILLISECONDS)
+ }
+
+ private def dieWithReason(reason: DamageInteraction): Unit = {
+ if (bot.isAlive) {
+ //primary death
+ val health = bot.Health
+ val psource = PlayerSource(bot)
+ bot.Health = 0
+ HandleDamage(
+ bot,
+ DamageResult(psource, psource.copy(health = 0), reason),
+ health,
+ damageToArmor = 0,
+ damageToStamina = 0
+ )
+ damageLog.info(s"${bot.Name}-infantry: dead by explicit reason - ${reason.cause.resolution}")
+ }
+ }
+
+ private def release(): Unit = {
+ bot.Zone.Population ! Zone.Bots.Release(bot)
+ spawnerActor ! ShootingRangeTargetSpawner.InfantryTargetReleased(bot)
+ scheduler.shutdown()
+ }
+
+ private def performEmote(): Unit = {
+ val zone = bot.Zone
+ zone.blockMap.sector(bot).livePlayerList.collect { t =>
+ zone.LocalEvents ! LocalServiceMessage(t.Name, LocalAction.SendResponse(TriggerBotAction(bot.GUID)))
+ }
+ }
+
+ private def tickLogic(): Unit = {
+ val zone = bot.Zone
+ if (!bot.Destroyed && zone.AllPlayers.size > 0) {
+ bot.zoneInteractions()
+ val rotateRNG = Random.nextDouble()
+ if (canRotate) {
+ if (rotateRNG > 0.95) {
+ val amount = 5f + Random.nextInt(10)
+ val finalRotation = if (Random.nextBoolean()) -amount else amount
+ bot.Orientation = Vector3(bot.Orientation.x, bot.Orientation.y, (bot.Orientation.z + finalRotation) % 360)
+ canRotate = false
+ //rotation cooldown
+ context.system.scheduler.scheduleOnce(
+ 2.seconds,
+ new Runnable() { override def run(): Unit = if (!bot.Destroyed) canRotate = true }
+ )
+ }
+ }
+ zone.AvatarEvents ! AvatarServiceMessage(
+ zone.id,
+ AvatarAction.PlayerState(
+ bot.GUID,
+ bot.Position,
+ bot.Velocity,
+ bot.Orientation.z,
+ bot.Orientation.y,
+ bot.FacingYawUpper,
+ 0,
+ bot.Crouching,
+ bot.Jumping,
+ false,
+ bot.Cloaked,
+ false,
+ false
+ )
+ )
+ if (canEmote) {
+ val emoteRNG = Random.nextDouble()
+ if (emoteRNG > 0.98) {
+ performEmote()
+ canEmote = false
+ //emote cooldown
+ context.system.scheduler.scheduleOnce(
+ 5.seconds,
+ new Runnable() { override def run(): Unit = if (!bot.Destroyed) canEmote = true }
+ )
+ }
+ }
+ }
+ }
+
+ /**
+ * Start the jammered buzzing.
+ * Although, as a rule, the jammering sound effect should last as long as the jammering status,
+ * Infantry seem to hear the sound for a bit longer than the effect.
+ * @see `JammableBehavior.StartJammeredSound`
+ * @param target an object that can be affected by the jammered status
+ * @param dur the duration of the timer, in milliseconds;
+ * by default, 30000
+ */
+ override def StartJammeredSound(target: Any, dur: Int): Unit =
+ target match {
+ case obj: AvatarBot if !jammedSound =>
+ obj.Zone.AvatarEvents ! AvatarServiceMessage(
+ obj.Zone.id,
+ AvatarAction.PlanetsideAttributeToAll(obj.GUID, 27, 1)
+ )
+ super.StartJammeredSound(obj, 3000)
+ case _ => ;
+ }
+
+ /**
+ * Perform a variety of tasks to indicate being jammered.
+ * Deactivate implants (should also uninitialize them),
+ * delay stamina regeneration for a certain number of turns,
+ * and set the jammered status on specific holstered equipment.
+ * @see `JammableBehavior.StartJammeredStatus`
+ * @param target an object that can be affected by the jammered status
+ * @param dur the duration of the timer, in milliseconds
+ */
+ override def StartJammeredStatus(target: Any, dur: Int): Unit = {
+ super.StartJammeredStatus(target, dur)
+ }
+
+ override def CancelJammeredStatus(target: Any): Unit = {
+ super.CancelJammeredStatus(target)
+ }
+
+ /**
+ * Stop the jammered buzzing.
+ * @see `JammableBehavior.CancelJammeredSound`
+ * @param target an object that can be affected by the jammered status
+ */
+ override def CancelJammeredSound(target: Any): Unit =
+ target match {
+ case obj: AvatarBot if jammedSound =>
+ obj.Zone.AvatarEvents ! AvatarServiceMessage(
+ obj.Zone.id,
+ AvatarAction.PlanetsideAttributeToAll(obj.GUID, 27, 0)
+ )
+ super.CancelJammeredSound(obj)
+ case _ => ;
+ }
+
+ def RepairToolValue(item: Tool): Float = {
+ item.AmmoSlot.Box.Definition.repairAmount +
+ (if (bot.ExoSuit != ExoSuitType.MAX) {
+ item.FireMode.Add.Damage0
+ }
+ else {
+ item.FireMode.Add.Damage3
+ })
+ }
+
+ def UpdateAuraEffect(target: AuraEffectBehavior.Target) : Unit = {
+ import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage}
+ val zone = target.Zone
+ val value = target.Aura.foldLeft(0)(_ + AvatarBotActor.auraEffectToAttributeValue(_))
+ zone.AvatarEvents ! AvatarServiceMessage(zone.id, AvatarAction.PlanetsideAttributeToAll(target.GUID, 54, value))
+ }
+}
+
+object AvatarBotActor {
+ /**
+ * Transform an applicable Aura effect into its `PlanetsideAttributeMessage` value.
+ * @see `Aura`
+ * @see `PlanetsideAttributeMessage`
+ * @param effect the aura effect
+ * @return the attribute value for that effect
+ */
+ private def auraEffectToAttributeValue(effect: Aura): Int = effect match {
+ case Aura.Plasma => 1
+ case Aura.Comet => 2
+ case Aura.Napalm => 4
+ case Aura.Fire => 8
+ case _ => 0
+ }
+}
diff --git a/src/main/scala/net/psforever/objects/avatar/PlayerControl.scala b/src/main/scala/net/psforever/objects/avatar/PlayerControl.scala
index 955b9886e..d597ea565 100644
--- a/src/main/scala/net/psforever/objects/avatar/PlayerControl.scala
+++ b/src/main/scala/net/psforever/objects/avatar/PlayerControl.scala
@@ -417,11 +417,15 @@ class PlayerControl(player: Player, avatarActor: typed.ActorRef[AvatarActor.Comm
}
val hasCavernEquipmentBenefit: Boolean =
terminalOpt.exists { terminal =>
- terminal.Owner match {
- case fac: Building =>
- fac.hasCavernLockBenefit && player.Faction == fac.Faction
- case _ =>
- false
+ if (terminal.IsInVRZone) {
+ true
+ } else {
+ terminal.Owner match {
+ case fac: Building =>
+ fac.hasCavernLockBenefit && player.Faction == fac.Faction
+ case _ =>
+ false
+ }
}
}
val dropPred =
diff --git a/src/main/scala/net/psforever/objects/ballistics/InteractWithRadiationClouds.scala b/src/main/scala/net/psforever/objects/ballistics/InteractWithRadiationClouds.scala
index 19e18316d..6a9365de7 100644
--- a/src/main/scala/net/psforever/objects/ballistics/InteractWithRadiationClouds.scala
+++ b/src/main/scala/net/psforever/objects/ballistics/InteractWithRadiationClouds.scala
@@ -27,17 +27,28 @@ class InteractWithRadiationClouds(
val position = target.Position
projectiles
.foreach { projectile =>
- target.Actor ! Vitality.Damage(
- DamageInteraction(
- SourceEntry(target),
- RadiationReason(
- ProjectileQuality.modifiers(projectile, DamageResolution.Radiation, target, target.Position, user),
- target.DamageModel,
- RadiationCloudInteraction.RadiationShieldingFrom(target)
- ),
- position
- ).calculate()
- )
+ val shouldDamage = user match {
+ case Some(player) if (player.IsInVRZone && target.Faction == player.Faction) =>
+ //disable self-damage and friendly-fire in VR zones
+ false
+ case Some(player) =>
+ true
+ case None =>
+ true
+ }
+ if (shouldDamage) {
+ target.Actor ! Vitality.Damage(
+ DamageInteraction(
+ SourceEntry(target),
+ RadiationReason(
+ ProjectileQuality.modifiers(projectile, DamageResolution.Radiation, target, target.Position, user),
+ target.DamageModel,
+ RadiationCloudInteraction.RadiationShieldingFrom(target)
+ ),
+ position
+ ).calculate()
+ )
+ }
}
}
}
diff --git a/src/main/scala/net/psforever/objects/definition/AvatarBotDefinition.scala b/src/main/scala/net/psforever/objects/definition/AvatarBotDefinition.scala
new file mode 100644
index 000000000..128671a62
--- /dev/null
+++ b/src/main/scala/net/psforever/objects/definition/AvatarBotDefinition.scala
@@ -0,0 +1,33 @@
+// Copyright (c) 2026 PSForever
+package net.psforever.objects.definition
+
+import net.psforever.objects.avatar.Avatars
+import net.psforever.objects.definition.converter.AvatarBotConverter
+import net.psforever.objects.geometry.GeometryForm
+import net.psforever.objects.vital.VitalityDefinition
+
+/**
+ * The definition for game objects that look like players.
+ * @param objectId the object type number
+ */
+class AvatarBotDefinition(objectId: Int) extends ObjectDefinition(objectId) with VitalityDefinition {
+ Name = "avatar_bot"
+ Avatars(objectId) //let throw NoSuchElementException
+ Packet = AvatarBotDefinition.converter
+ Geometry = GeometryForm.representPlayerByCylinder(radius = 1.6f)
+ //do NOT attempt to create AvatarBot's outside of the VR Shooting Range zones with this definition,
+ //space will need to be made to add a "bots" pool to the target zone
+ registerAs = "bots"
+}
+
+object AvatarBotDefinition {
+ private val converter = new AvatarBotConverter()
+
+ def apply(objectId: Int): AvatarBotDefinition = {
+ new AvatarBotDefinition(objectId)
+ }
+
+ def apply(avatar: Avatars.Value): AvatarBotDefinition = {
+ new AvatarBotDefinition(avatar.id)
+ }
+}
diff --git a/src/main/scala/net/psforever/objects/definition/converter/AvatarBotConverter.scala b/src/main/scala/net/psforever/objects/definition/converter/AvatarBotConverter.scala
new file mode 100644
index 000000000..44ad86bd3
--- /dev/null
+++ b/src/main/scala/net/psforever/objects/definition/converter/AvatarBotConverter.scala
@@ -0,0 +1,306 @@
+// Copyright (c) 2026 PSForever
+package net.psforever.objects.definition.converter
+
+import net.psforever.objects.Player
+import net.psforever.objects.avatar.{AvatarBot, BattleRank}
+import net.psforever.objects.equipment.{Equipment, EquipmentSlot}
+import net.psforever.packet.game.objectcreate._
+import net.psforever.types.{ExoSuitType, GrenadeState, PlanetSideEmpire, PlanetSideGUID}
+
+import scala.annotation.tailrec
+import scala.util.{Success, Try}
+
+class AvatarBotConverter extends ObjectCreateConverter[AvatarBot]() {
+ override def ConstructorData(obj: AvatarBot): Try[PlayerData] = {
+ import AvatarBotConverter._
+ Success(
+ PlayerData(
+ PlacementData(obj.Position, obj.Orientation, None),
+ MakeAppearanceData(obj),
+ MakeCharacterData(obj),
+ MakeInventoryData(obj),
+ GetDrawnSlot(obj)
+ )
+ )
+ }
+
+ override def DetailedConstructorData(obj: AvatarBot): Try[DetailedPlayerData] = {
+ import AvatarBotConverter._
+ Success(
+ DetailedPlayerData.apply(
+ PlacementData(obj.Position, obj.Orientation, None),
+ MakeAppearanceData(obj),
+ MakeDetailedCharacterData(obj),
+ MakeDetailedInventoryData(obj),
+ GetDrawnSlot(obj)
+ )
+ )
+ }
+}
+
+object AvatarBotConverter {
+
+ /**
+ * Compose some data from a `AvatarBot` into a representation common to both `CharacterData` and `DetailedCharacterData`.
+ * @param obj the `AvatarBot` game object
+ * @return the resulting `CharacterAppearanceData`
+ */
+ def MakeAppearanceData(obj: AvatarBot): Int => CharacterAppearanceData = {
+ val aa: Int => CharacterAppearanceA = CharacterAppearanceA(
+ obj.basic,
+ CommonFieldData(
+ obj.Faction,
+ bops = false,
+ false,
+ v1 = false,
+ None,
+ obj.Jammed,
+ None,
+ v5 = None,
+ PlanetSideGUID(0)
+ ),
+ obj.ExoSuit,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ )
+ val ab: (Boolean, Int) => CharacterAppearanceB = CharacterAppearanceB(
+ 0,
+ "",
+ outfit_logo = 0,
+ unk1 = false,
+ false,
+ unk2 = false,
+ unk3 = false,
+ unk4 = false,
+ facingPitch = obj.Orientation.y,
+ facingYawUpper = obj.FacingYawUpper,
+ false,
+ GrenadeState.None,
+ obj.Cloaked,
+ unk5 = false,
+ unk6 = false,
+ charging_pose = false,
+ unk7 = false,
+ on_zipline = None
+ )
+ CharacterAppearanceData(aa, ab, obj.decoration.ribbonBars)
+ }
+
+ def MakeCharacterData(obj: AvatarBot): (Boolean, Boolean) => CharacterData = {
+ val uniformStyle = obj.br.uniformStyle
+ val cosmetics = if (BattleRank.showCosmetics(uniformStyle)) {
+ obj.decoration.cosmetics
+ } else {
+ None
+ }
+ val MaxArmor = obj.MaxArmor
+ val armor = if (MaxArmor == 0) {
+ 0
+ } else {
+ StatConverter.Health(obj.Armor, MaxArmor)
+ }
+ CharacterData(
+ StatConverter.Health(obj.Health, obj.MaxHealth),
+ armor,
+ uniformStyle,
+ 0,
+ obj.cr.value,
+ obj.implants.flatten.filter(_.active).flatMap(_.definition.implantType.effect).toList,
+ cosmetics
+ )
+ }
+
+ def MakeDetailedCharacterData(obj: AvatarBot): Option[Int] => DetailedCharacterData = {
+ val maxOpt: Option[Long] = if (obj.ExoSuit == ExoSuitType.MAX) {
+ Some(0L)
+ } else {
+ None
+ }
+ val cosmetics = if (BattleRank.BR24.experience >= obj.bep) {
+ obj.decoration.cosmetics
+ } else {
+ None
+ }
+ val ba: DetailedCharacterA = DetailedCharacterA(
+ obj.bep,
+ obj.cep,
+ 0L,
+ 0L,
+ 0L,
+ obj.MaxHealth,
+ obj.Health,
+ unk4 = false,
+ obj.Armor,
+ 0L,
+ obj.maxStamina,
+ obj.stamina,
+ maxOpt,
+ 0,
+ 0,
+ 0L,
+ List(0, 0, 0, 0, 0, 0),
+ obj.certifications.toList.sortBy(_.value) //TODO is sorting necessary?
+ )
+ val bb: (Long, Option[Int]) => DetailedCharacterB = DetailedCharacterB(
+ None,
+ Nil,
+ Nil,
+ Nil,
+ Nil,
+ tutorials = List.empty[String], //TODO tutorial list
+ 0L,
+ 0L,
+ 0L,
+ 0L,
+ 0L,
+ None, //Some(ImprintingProgress(0, 0)),
+ Nil,
+ Nil,
+ unkC = false,
+ cosmetics
+ )
+ pad_length: Option[Int] => DetailedCharacterData(ba, bb(0, pad_length))(pad_length)
+ }
+
+ def MakeInventoryData(obj: AvatarBot): InventoryData = {
+ InventoryData(MakeHolsters(obj, BuildEquipment))
+ }
+
+ def MakeDetailedInventoryData(obj: AvatarBot): InventoryData = {
+ InventoryData(
+ MakeHolsters(obj, BuildDetailedEquipment) ++
+ MakeFifthSlot(obj) ++
+ MakeInventory(obj)
+ )
+ }
+
+ /**
+ * Given a player with an inventory, convert the contents of that inventory into converted-decoded packet data.
+ * The inventory is not represented in a `0x17` `AvatarBot`, so the conversion is only valid for `0x18` avatars.
+ * It will always be "`Detailed`".
+ * @param obj the `AvatarBot` game object
+ * @return a list of all items that were in the inventory in decoded packet form
+ */
+ private def MakeInventory(obj: AvatarBot): List[InternalSlot] = {
+ obj.Inventory.Items
+ .map(item => {
+ val equip: Equipment = item.obj
+ InternalSlot(
+ equip.Definition.ObjectId,
+ equip.GUID,
+ item.start,
+ equip.Definition.Packet.DetailedConstructorData(equip).get
+ )
+ })
+ }
+
+ /**
+ * Given a player with equipment holsters, convert the contents of those holsters into converted-decoded packet data.
+ * The decoded packet form is determined by the function in the parameters as both `0x17` and `0x18` conversions are available,
+ * with exception to the contents of the fifth slot.
+ * The fifth slot is only represented if the `AvatarBot` is an `0x18` type.
+ * @param obj the `AvatarBot` game object
+ * @param builder the function used to transform to the decoded packet form
+ * @return a list of all items that were in the holsters in decoded packet form
+ */
+ def MakeHolsters(obj: AvatarBot, builder: (Int, Equipment) => InternalSlot): List[InternalSlot] = {
+ recursiveMakeHolsters(obj.Holsters().iterator, builder)
+ }
+
+ /**
+ * Given a player with equipment holsters, convert any content of the fifth holster slot into converted-decoded packet data.
+ * The fifth holster is a curious divider between the standard holsters and the formal inventory.
+ * This fifth slot is only ever represented if the `AvatarBot` is an `0x18` type.
+ * @param obj the `AvatarBot` game object
+ * @return a list of any item that was in the fifth holster in decoded packet form
+ */
+ private def MakeFifthSlot(obj: AvatarBot): List[InternalSlot] = {
+ obj.Slot(slot = 5).Equipment match {
+ case Some(equip) =>
+ List(InternalSlot(
+ equip.Definition.ObjectId,
+ equip.GUID,
+ 5,
+ DetailedLockerContainerData(
+ CommonFieldData(PlanetSideEmpire.NEUTRAL, bops=false, alternate=false, v1=true, None, jammered=false, None, None, PlanetSideGUID(0)),
+ None
+ )
+ ))
+ case _ =>
+ Nil
+ }
+ }
+
+ /**
+ * A builder method for turning an object into `0x17` decoded packet form.
+ * @param index the position of the object
+ * @param equip the game object
+ * @return the game object in decoded packet form
+ */
+ private def BuildEquipment(index: Int, equip: Equipment): InternalSlot = {
+ InternalSlot(equip.Definition.ObjectId, equip.GUID, index, equip.Definition.Packet.ConstructorData(equip).get)
+ }
+
+ /**
+ * A builder method for turning an object into `0x18` decoded packet form.
+ * @param index the position of the object
+ * @param equip the game object
+ * @return the game object in decoded packet form
+ */
+ def BuildDetailedEquipment(index: Int, equip: Equipment): InternalSlot = {
+ InternalSlot(
+ equip.Definition.ObjectId,
+ equip.GUID,
+ index,
+ equip.Definition.Packet.DetailedConstructorData(equip).get
+ )
+ }
+
+ /**
+ * Given some equipment holsters, convert the contents of those holsters into converted-decoded packet data.
+ * @param iter an `Iterator` of `EquipmentSlot` objects that are a part of the player's holsters
+ * @param builder the function used to transform to the decoded packet form
+ * @param list the current `List` of transformed data
+ * @param index which holster is currently being explored
+ * @return the `List` of inventory data created from the holsters
+ */
+ @tailrec private def recursiveMakeHolsters(
+ iter: Iterator[EquipmentSlot],
+ builder: (Int, Equipment) => InternalSlot,
+ list: List[InternalSlot] = Nil,
+ index: Int = 0
+ ): List[InternalSlot] = {
+ if (!iter.hasNext) {
+ list
+ } else {
+ val slot: EquipmentSlot = iter.next()
+ if (slot.Equipment.isDefined) {
+ val equip: Equipment = slot.Equipment.get
+ recursiveMakeHolsters(
+ iter,
+ builder,
+ list :+ builder(index, equip),
+ index + 1
+ )
+ } else {
+ recursiveMakeHolsters(iter, builder, list, index + 1)
+ }
+ }
+ }
+
+ /**
+ * Resolve which holster the player has drawn, if any.
+ * @param obj the `AvatarBot` game object
+ * @return the holster's Enumeration value
+ */
+ def GetDrawnSlot(obj: AvatarBot): DrawnSlot.Value = {
+ obj.DrawnSlot match {
+ case Player.HandsDownSlot | Player.FreeHandSlot => DrawnSlot.None
+ case n => DrawnSlot(n)
+ }
+ }
+}
diff --git a/src/main/scala/net/psforever/objects/equipment/EffectTarget.scala b/src/main/scala/net/psforever/objects/equipment/EffectTarget.scala
index 867db90a7..e0eea8f8b 100644
--- a/src/main/scala/net/psforever/objects/equipment/EffectTarget.scala
+++ b/src/main/scala/net/psforever/objects/equipment/EffectTarget.scala
@@ -2,6 +2,7 @@
package net.psforever.objects.equipment
import net.psforever.objects._
+import net.psforever.objects.avatar.AvatarBot
import net.psforever.objects.ce.{DeployableCategory, DeployedItem}
import net.psforever.objects.serverobject.turret.{FacilityTurret, WeaponTurret}
import net.psforever.objects.vital.{DamagingActivity, InGameHistory, Vitality}
@@ -111,6 +112,8 @@ object EffectTarget {
target match {
case p: Player =>
p.isAlive
+ case b: AvatarBot =>
+ b.isAlive
case _ =>
false
}
diff --git a/src/main/scala/net/psforever/objects/geometry/GeometryForm.scala b/src/main/scala/net/psforever/objects/geometry/GeometryForm.scala
index 070ae972e..fcd1daee1 100644
--- a/src/main/scala/net/psforever/objects/geometry/GeometryForm.scala
+++ b/src/main/scala/net/psforever/objects/geometry/GeometryForm.scala
@@ -1,6 +1,7 @@
// Copyright (c) 2021 PSForever
package net.psforever.objects.geometry
+import net.psforever.objects.avatar.AvatarBot
import net.psforever.objects.ballistics.Projectile
import net.psforever.objects.geometry.d3._
import net.psforever.objects.sourcing.{PlayerSource, SourceEntry}
@@ -152,6 +153,13 @@ object GeometryForm {
radius + radialOffset,
heightOffset
)
+ case b: AvatarBot =>
+ val radialOffset = if(b.ExoSuit == ExoSuitType.MAX) 0.25f else 0f
+ Cylinder(
+ b.Position,
+ radius + radialOffset,
+ GlobalDefinitions.MaxDepth(b)
+ )
case _ =>
invalidCylinder
}
diff --git a/src/main/scala/net/psforever/objects/guid/GUIDTask.scala b/src/main/scala/net/psforever/objects/guid/GUIDTask.scala
index cd86cfafc..51eb7ef3f 100644
--- a/src/main/scala/net/psforever/objects/guid/GUIDTask.scala
+++ b/src/main/scala/net/psforever/objects/guid/GUIDTask.scala
@@ -2,6 +2,7 @@
package net.psforever.objects.guid
import akka.util.Timeout
+import net.psforever.objects.avatar.AvatarBot
import net.psforever.objects.entity.IdentifiableEntity
import net.psforever.objects.equipment.{Equipment, EquipmentSlot}
import net.psforever.objects._
@@ -203,6 +204,19 @@ object GUIDTask {
TaskBundle(RegisterObjectTask(guid, tplayer), holsterTasks ++ inventoryTasks)
}
+ /**
+ * Construct tasking that registers an object with a globally unique identifier selected from a pool of numbers, as an `AvatarBot`.
+ *
+ * @param bot the `AvatarBot` object being registered
+ * @param guid implicit reference to a unique number system
+ * @return a `TaskBundle` message
+ */
+ def registerBot(guid: UniqueNumberOps, bot: AvatarBot): TaskBundle = {
+ val holsterTasks = visibleSlotTaskBuilding(guid, bot.Holsters(), registerEquipment)
+ val inventoryTasks = registerInventory(guid, bot)
+ TaskBundle(RegisterObjectTask(guid, bot), holsterTasks ++ inventoryTasks)
+ }
+
/**
* Construct tasking that registers an object with a globally unique identifier selected from a pool of numbers, as a `Vehicle`.
*
@@ -370,6 +384,21 @@ object GUIDTask {
TaskBundle(UnregisterObjectTask(guid, tplayer), holsterTasks ++ inventoryTasks)
}
+ /**
+ * Construct tasking that unregisters a `AvatarBot` object from a globally unique identifier system.
+ *
+ * This task performs an operation that reverses the effect of `RegisterBot`.
+ * @param bot the `AvatarBot` object being unregistered
+ * @param guid implicit reference to a unique number system
+ * @see `GUIDTask.registerAvatar`
+ * @return a `TaskBundle` message
+ */
+ def unregisterBot(guid: UniqueNumberOps, bot: AvatarBot): TaskBundle = {
+ val holsterTasks = visibleSlotTaskBuilding(guid, bot.Holsters(), unregisterEquipment)
+ val inventoryTasks = unregisterInventory(guid, bot)
+ TaskBundle(UnregisterObjectTask(guid, bot), holsterTasks ++ inventoryTasks)
+ }
+
/**
* Construct tasking that unregisters a `Vehicle` object from a globally unique identifier system.
*
diff --git a/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/ExclusionRule.scala b/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/ExclusionRule.scala
index 6ff87ed0b..fe2cff23d 100644
--- a/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/ExclusionRule.scala
+++ b/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/ExclusionRule.scala
@@ -67,13 +67,14 @@ case object NoCavernEquipmentRule extends ExclusionRule {
/**
* Do not allow the player to spawn cavern equipment if not pulled from a facility and
* only if the facility is subject to the benefit of an appropriate cavern perk.
+ * This is bypassed for VR Training terminals.
*/
case object CavernEquipmentQuestion extends ExclusionRule {
def checkRule(player: Player, msg: ItemTransactionMessage, obj: Any): Boolean = {
obj match {
case equipment: Equipment =>
import net.psforever.objects.serverobject.structures.Building
- if(GlobalDefinitions.isCavernWeapon(equipment.Definition)) {
+ if(GlobalDefinitions.isCavernWeapon(equipment.Definition) && !player.IsInVRZone) {
(player.Zone.GUID(msg.terminal_guid) match {
case Some(term: Amenity) => Some(term.Owner)
case _ => None
@@ -106,6 +107,7 @@ final case class NoVehicleRule(illegalDefinition: VehicleDefinition) extends Exc
/**
* Do not allow the player to spawn cavern vehicles if not pulled from a facility and
* only if the facility is subject to the benefit of an appropriate cavern perk.
+ * This is bypassed for VR Training terminals.
*/
case object CavernVehicleQuestion extends ExclusionRule {
def checkRule(player: Player, msg: ItemTransactionMessage, obj: Any): Boolean = {
@@ -113,7 +115,7 @@ case object CavernVehicleQuestion extends ExclusionRule {
case vehicle: Vehicle =>
import net.psforever.objects.serverobject.structures.Building
val definition = vehicle.Definition
- if (definition == GlobalDefinitions.flail || definition == GlobalDefinitions.switchblade) {
+ if ((definition == GlobalDefinitions.flail || definition == GlobalDefinitions.switchblade) && !player.IsInVRZone) {
(player.Zone.GUID(msg.terminal_guid) match {
case Some(term: Amenity) => Some(term.Owner)
case _ => None
diff --git a/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/VehiclePage.scala b/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/VehiclePage.scala
index 8c553b051..3f9716d7c 100644
--- a/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/VehiclePage.scala
+++ b/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/VehiclePage.scala
@@ -24,7 +24,7 @@ final case class VehiclePage(stock: Map[String, () => Vehicle], trunk: Map[Strin
stock.get(msg.item_name) match {
case Some(vehicle) =>
val createdVehicle = vehicle()
- if(!Exclude.exists(_.checkRule(player, msg, createdVehicle)) || player.IsInVRZone) {
+ if(!Exclude.exists(_.checkRule(player, msg, createdVehicle))) {
val (weapons, inventory) = trunk.get(msg.item_name) match {
case Some(loadout: VehicleLoadout) =>
(
diff --git a/src/main/scala/net/psforever/objects/sourcing/PlayerSource.scala b/src/main/scala/net/psforever/objects/sourcing/PlayerSource.scala
index 042ed7db3..b919687d1 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.AvatarBot
import net.psforever.objects.avatar.scoring.Life
import net.psforever.objects.definition.{AvatarDefinition, ExoSuitDefinition}
import net.psforever.objects.serverobject.affinity.FactionAffinity
@@ -72,6 +73,27 @@ object PlayerSource {
)
}
+ def apply(b: AvatarBot): PlayerSource = {
+ val exosuit = b.ExoSuit
+ val faction = b.Faction
+ PlayerSource(
+ GlobalDefinitions.avatar,
+ exosuit,
+ seatedIn = None,
+ b.Health,
+ b.Armor,
+ b.Position,
+ b.Orientation,
+ b.Velocity,
+ b.Crouching,
+ b.Jumping,
+ ExoSuitDefinition.Select(exosuit, faction),
+ b.bep,
+ progress = tokenLife,
+ UniquePlayer(0L, b.Name, b.Sex, faction)
+ )
+ }
+
def apply(name: String, faction: PlanetSideEmpire.Value, position: Vector3): PlayerSource = {
this(UniquePlayer(0L, name, CharacterSex.Male, faction), position)
}
diff --git a/src/main/scala/net/psforever/objects/sourcing/SourceEntry.scala b/src/main/scala/net/psforever/objects/sourcing/SourceEntry.scala
index 81b20bf40..d2c209707 100644
--- a/src/main/scala/net/psforever/objects/sourcing/SourceEntry.scala
+++ b/src/main/scala/net/psforever/objects/sourcing/SourceEntry.scala
@@ -1,6 +1,7 @@
// Copyright (c) 2017 PSForever
package net.psforever.objects.sourcing
+import net.psforever.objects.avatar.AvatarBot
import net.psforever.objects.ce.Deployable
import net.psforever.objects.definition.ObjectDefinition
import net.psforever.objects.serverobject.affinity.FactionAffinity
@@ -49,6 +50,7 @@ object SourceEntry {
def apply(target: PlanetSideGameObject with FactionAffinity): SourceEntry = {
target match {
case obj: Player => PlayerSource(obj)
+ case obj: AvatarBot => PlayerSource(obj)
case obj: Vehicle => VehicleSource(obj)
case obj: FacilityTurret => TurretSource(obj)
case obj: Amenity => AmenitySource(obj)
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 78c423197..adf02e282 100644
--- a/src/main/scala/net/psforever/objects/vehicles/control/VehicleControl.scala
+++ b/src/main/scala/net/psforever/objects/vehicles/control/VehicleControl.scala
@@ -2,7 +2,7 @@
package net.psforever.objects.vehicles.control
import akka.actor.Cancellable
-import net.psforever.actors.zone.ZoneActor
+import net.psforever.actors.zone.{ShootingRangeTargetSpawner, ZoneActor}
import net.psforever.objects._
import net.psforever.objects.avatar.SpecialCarry
import net.psforever.objects.definition.{VehicleDefinition, WithShields}
@@ -266,6 +266,15 @@ class VehicleControl(vehicle: Vehicle)
PrepareForDisabled(kickPassengers)
context.become(Disabled)
+ case Vehicle.Deconstruct(_) if (vehicle.Zone.id.startsWith("tzsh") && vehicle.OwnerGuid.isEmpty) =>
+ //deconstruct the vehicle immediately if this is a VR Shooting Range target
+ context.become(ReadyToDelete)
+ //cancel jammed behavior
+ CancelJammeredSound(vehicle)
+ CancelJammeredStatus(vehicle)
+ self ! VehicleControl.Deletion()
+ vehicle.ClearHistory()
+
case Vehicle.Deconstruct(time) =>
time match {
case Some(delay) if vehicle.Definition.undergoesDecay =>
@@ -326,6 +335,13 @@ class VehicleControl(vehicle: Vehicle)
VehicleAction.UnloadVehicle(Service.defaultPlayerGUID, vehicle, vehicle.GUID)
)
zone.Transport.tell(Zone.Vehicle.Despawn(vehicle), zone.Transport)
+ //notify target spawner that the vehicle has despawned if this is a VR Shooting Range zone
+ //todo: make this behavior cleaner
+ if (zone.id.startsWith("tzsh") && vehicle.OwnerGuid.isEmpty && zone.NPCPopulation != Default.Actor) {
+ zone.NPCPopulation.tell(ShootingRangeTargetSpawner.VehicleTargetDeconstructed(vehicle), zone.NPCPopulation)
+ }
+ //unregister
+ TaskWorkflow.execute(GUIDTask.unregisterVehicle(zone.GUID, vehicle))
}
final def ReadyToDelete: Receive = commonDeleteBehavior
@@ -448,8 +464,16 @@ class VehicleControl(vehicle: Vehicle)
//cancel jammed behavior
CancelJammeredSound(vehicle)
CancelJammeredStatus(vehicle)
- //unregister
- TaskWorkflow.execute(GUIDTask.unregisterVehicle(zone.GUID, vehicle))
+ if (!vehicle.OwnerGuid.isEmpty) {
+ //remove vehicle ownership is not already removed
+ val obj = MountableObject
+ Vehicles.Disown(obj.GUID, obj)
+ }
+ //lock the vehicle to prevent interaction attempts during deletion
+ vehicle.PermissionGroup(0, 0)
+ vehicle.PermissionGroup(1, 0)
+ vehicle.PermissionGroup(2, 0)
+ vehicle.PermissionGroup(3, 0)
//banished to the shadow realm
vehicle.Position = Vector3.Zero
vehicle.ClearHistory()
diff --git a/src/main/scala/net/psforever/objects/vital/resolution/ResolutionCalculations.scala b/src/main/scala/net/psforever/objects/vital/resolution/ResolutionCalculations.scala
index 44774f2c8..b84630ddf 100644
--- a/src/main/scala/net/psforever/objects/vital/resolution/ResolutionCalculations.scala
+++ b/src/main/scala/net/psforever/objects/vital/resolution/ResolutionCalculations.scala
@@ -2,6 +2,7 @@
package net.psforever.objects.vital.resolution
import net.psforever.objects._
+import net.psforever.objects.avatar.AvatarBot
import net.psforever.objects.ce.Deployable
import net.psforever.objects.serverobject.affinity.FactionAffinity
import net.psforever.objects.serverobject.damage.Damageable
@@ -236,6 +237,50 @@ object ResolutionCalculations {
player.avatar.copy(stamina = math.max(0, player.avatar.stamina - math.floor(delta / 2).toInt))
}
}
+ case bot: AvatarBot if noDoubleLash(bot, data) =>
+ var (a, b) = damageValues
+ if (bot.isAlive && !(a == 0 && b == 0)) {
+ val originalHealth = bot.Health
+ if (data.cause.source.DamageToHealthOnly) {
+ bot.Health = SubtractWithRemainder(bot.Health, a)._1
+ } else {
+ var result = (0, 0)
+ bot.implants.flatten.find(x => x.definition.implantType == ImplantType.PersonalShield) match {
+ case Some(implant) if implant.active =>
+ // Subtract armour damage from stamina
+ result = SubtractWithRemainder(bot.stamina, b)
+ bot.stamina = result._1
+ b = result._2
+
+ // Then follow up with health damage if any stamina is left
+ result = SubtractWithRemainder(bot.stamina, a)
+ bot.stamina = result._1
+ a = result._2
+
+ case _ => ;
+ }
+
+ // Subtract any remaining armour damage from armour
+ result = SubtractWithRemainder(bot.Armor, b)
+ bot.Armor = result._1
+ b = result._2
+ // Then bleed through to health if armour ran out
+ result = SubtractWithRemainder(bot.Health, b)
+ bot.Health = result._1
+ b = result._2
+
+ // Finally, apply health damage to health
+ result = SubtractWithRemainder(bot.Health, a)
+ bot.Health = result._1
+ //if b > 0 (armor) or result._2 > 0 (health), then we did the math wrong
+ }
+
+ // If any health damage was applied also drain an amount of stamina equal to half the health damage
+ if (bot.Health < originalHealth) {
+ val delta = originalHealth - bot.Health
+ bot.stamina = math.max(0, bot.stamina - math.floor(delta / 2).toInt)
+ }
+ }
case _ =>
}
DamageResult(targetBefore, SourceEntry(target), data)
@@ -336,7 +381,7 @@ object ResolutionCalculations {
def WildcardApplication(damage: Any, data: DamageInteraction)(target: PlanetSideGameObject with FactionAffinity): DamageResult = {
target match {
- case _: Player =>
+ case _: Player | _: AvatarBot =>
val dam : (Int, Int) = damage match {
case (a: Int, b: Int) => (a, b)
case a: Int => (a, 0)
diff --git a/src/main/scala/net/psforever/objects/zones/Zone.scala b/src/main/scala/net/psforever/objects/zones/Zone.scala
index 60cc751dd..61ee9a1fc 100644
--- a/src/main/scala/net/psforever/objects/zones/Zone.scala
+++ b/src/main/scala/net/psforever/objects/zones/Zone.scala
@@ -32,9 +32,9 @@ import scalax.collection.GraphEdge._
import scala.util.Try
import akka.actor.typed
import net.psforever.actors.session.AvatarActor
-import net.psforever.actors.zone.{BuildingActor, ZoneActor}
+import net.psforever.actors.zone.{BuildingActor, ShootingRangeTargetSpawnerActor, ZoneActor}
import net.psforever.actors.zone.building.WarpGateLogic
-import net.psforever.objects.avatar.{Avatar, PlayerControl}
+import net.psforever.objects.avatar.{Avatar, AvatarBot, PlayerControl}
import net.psforever.objects.definition.ObjectDefinition
import net.psforever.objects.geometry.d3.VolumetricGeometry
import net.psforever.objects.guid.pool.NumberPool
@@ -134,6 +134,10 @@ class Zone(val id: String, val map: ZoneMap, zoneNumber: Int) {
*/
private val players: TrieMap[Int, Option[Player]] = TrieMap[Int, Option[Player]]()
+ /**
+ */
+ private val bots: ListBuffer[AvatarBot] = ListBuffer[AvatarBot]()
+
/**
*/
private val corpses: ListBuffer[Player] = ListBuffer[Player]()
@@ -145,6 +149,11 @@ class Zone(val id: String, val map: ZoneMap, zoneNumber: Int) {
*/
private var population: ActorRef = Default.Actor
+ /**
+ * Actor that handles non-player controlled entities, used for VR Shooting Range targets.
+ */
+ private var npcPopulation: ActorRef = Default.Actor
+
private var buildings: PairMap[Int, Building] = PairMap.empty[Int, Building]
private var lattice: Graph[Building, UnDiEdge] = Graph()
@@ -444,6 +453,8 @@ class Zone(val id: String, val map: ZoneMap, zoneNumber: Int) {
def Players: List[Avatar] = AllPlayers.map(_.avatar)
+ def BotAvatars: List[AvatarBot] = bots.toList
+
def LivePlayers: List[Player] = AllPlayers.filterNot(_.spectator)
def Spectator: List[Player] = AllPlayers.filter(_.spectator)
@@ -470,6 +481,8 @@ class Zone(val id: String, val map: ZoneMap, zoneNumber: Int) {
def Population: ActorRef = population
+ def NPCPopulation: ActorRef = npcPopulation
+
def Buildings: Map[Int, Building] = buildings
def Building(id: Int): Option[Building] = {
@@ -1221,6 +1234,21 @@ object Zone {
final case class PlayerCanNotSpawn(zone: Zone, player: Player)
}
+ object Bots {
+
+ /**
+ * Message that reports to the zone of a freshly spawned bot.
+ * @param bot the `AvatarBot`
+ */
+ final case class Spawn(bot: AvatarBot)
+
+ /**
+ * Message that tells the zone to no longer mind the bot.
+ * @param bot the `AvatarBot`
+ */
+ final case class Release(bot: AvatarBot)
+ }
+
object Corpse {
/**
@@ -1574,13 +1602,18 @@ object Zone {
zone.deployables = context.actorOf(Props(classOf[ZoneDeployableActor], zone, zone.constructions, zone.linkDynamicTurretWeapon), s"$id-deployables")
zone.projectiles = context.actorOf(Props(classOf[ZoneProjectileActor], zone, zone.projectileList), s"$id-projectiles")
zone.transport = context.actorOf(Props(classOf[ZoneVehicleActor], zone, zone.vehicles, zone.linkDynamicTurretWeapon), s"$id-vehicles")
- zone.population = context.actorOf(Props(classOf[ZonePopulationActor], zone, zone.players, zone.corpses), s"$id-players")
+ zone.population = context.actorOf(Props(classOf[ZonePopulationActor], zone, zone.players, zone.bots, zone.corpses), s"$id-players")
zone.projector = context.actorOf(
Props(classOf[ZoneHotSpotDisplay], zone, zone.hotspots, 15 seconds, zone.hotspotHistory, 60 seconds),
s"$id-hotspots"
)
zone.soi = context.actorOf(Props(classOf[SphereOfInfluenceActor], zone), s"$id-soi")
+ //enable target spawns for VR Shooting Range zones
+ if (zone.id.startsWith("tzsh")) {
+ zone.npcPopulation = context.actorOf(Props(classOf[ShootingRangeTargetSpawnerActor], zone), s"$id-npcs")
+ }
+
zone.avatarEvents = context.actorOf(Props(classOf[AvatarService], zone), s"$id-avatar-events")
zone.localEvents = context.actorOf(Props(classOf[LocalService], zone), s"$id-local-events")
zone.vehicleEvents = context.actorOf(Props(classOf[VehicleService], zone), s"$id-vehicle-events")
@@ -1943,9 +1976,14 @@ object Zone {
val allAffectedTargets = pssos.filter { target => testTargetsFromZone(source, target, radius) }
//inform remaining targets that they have suffered damage
allAffectedTargets
- .foreach { target =>
- if (zone.id.startsWith("tz") && source.Faction == target.Faction) {
- //do not perform friendly-fire in VR zones
+ .foreach { target =>
+ if (target.IsInVRZone) {
+ //disable all server-side damage in VR zones, unless the target is a bot in the VR Shooting Range
+ target match {
+ case bot: AvatarBot =>
+ target.Actor ! Vitality.Damage(createInteraction(source, target).calculate())
+ case _ =>
+ }
} else {
target.Actor ! Vitality.Damage(createInteraction(source, target).calculate())
}
@@ -2107,6 +2145,8 @@ object Zone {
//collect all targets that can be damaged
//players
val playerTargets = sector.livePlayerList.filterNot { _.VehicleSeated.nonEmpty }
+ //bots
+ val botTargets = sector.botList
//vehicles
val vehicleTargets = sector.vehicleList.filterNot { v => v.Destroyed || v.MountedIn.nonEmpty }
//deployables
@@ -2114,7 +2154,7 @@ object Zone {
//amenities
val soiTargets = sector.amenityList.collect { case amenity: Vitality if !amenity.Destroyed => amenity }
//altogether ...
- playerTargets ++ vehicleTargets ++ deployableTargets ++ soiTargets
+ playerTargets ++ botTargets ++ vehicleTargets ++ deployableTargets ++ soiTargets
}
/**
@@ -2140,6 +2180,8 @@ object Zone {
//collect all targets that can be damaged
//players
val playerTargets = sector.livePlayerList.filter { player => player.VehicleSeated.isEmpty && player.WhichSide == Sidedness.OutsideOf }
+ //bots
+ val botTargets = sector.botList.filter { bot => bot.WhichSide == Sidedness.OutsideOf }
//vehicles
val vehicleTargets = sector.vehicleList.filterNot { _.Destroyed }
//deployables
@@ -2148,7 +2190,7 @@ object Zone {
val soiTargets = sector.amenityList.collect {
case amenity: Vitality if !amenity.Destroyed && amenity.WhichSide == Sidedness.OutsideOf && amenity.CanDamage => amenity }
//altogether ...
- playerTargets ++ vehicleTargets ++ deployableTargets ++ soiTargets
+ playerTargets ++ botTargets ++ vehicleTargets ++ deployableTargets ++ soiTargets
}
/**
diff --git a/src/main/scala/net/psforever/objects/zones/ZonePopulationActor.scala b/src/main/scala/net/psforever/objects/zones/ZonePopulationActor.scala
index f7480a8a6..e972808d6 100644
--- a/src/main/scala/net/psforever/objects/zones/ZonePopulationActor.scala
+++ b/src/main/scala/net/psforever/objects/zones/ZonePopulationActor.scala
@@ -3,17 +3,18 @@ package net.psforever.objects.zones
import akka.actor.{Actor, ActorRef, Props}
import net.psforever.actors.zone.ZoneActor
-import net.psforever.objects.avatar.{CorpseControl, PlayerControl}
+import net.psforever.objects.avatar.{AvatarBot, CorpseControl, PlayerControl}
import net.psforever.objects.sourcing.PlayerSource
import net.psforever.objects.vital.{InGameHistory, SpawningActivity}
import net.psforever.objects.{Default, Player}
+import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage}
import net.psforever.types.Vector3
import scala.collection.concurrent.TrieMap
import scala.collection.mutable.ListBuffer
/**
- * A support `Actor` that sequences adding and removing `Avatar` and `Player` objects to mappings and lists.
+ * A support `Actor` that sequences adding and removing `Avatar`, `AvatarBot`, and `Player` objects to mappings and lists.
* The former mapping is considered to represent every user connect to the `zone` (`as Avatar` objects)
* and their current representation (as `Player` objects).
* The latter list keeps track of a group of former user representations.
@@ -22,7 +23,7 @@ import scala.collection.mutable.ListBuffer
* @param playerMap the mapping of `Avatar` objects to `Player` objects
* @param corpseList a list of `Player` objects
*/
-class ZonePopulationActor(zone: Zone, playerMap: TrieMap[Int, Option[Player]], corpseList: ListBuffer[Player])
+class ZonePopulationActor(zone: Zone, playerMap: TrieMap[Int, Option[Player]], botList: ListBuffer[AvatarBot], corpseList: ListBuffer[Player])
extends Actor {
import ZonePopulationActor._
@@ -83,6 +84,27 @@ class ZonePopulationActor(zone: Zone, playerMap: TrieMap[Int, Option[Player]], c
sender() ! Zone.Population.PlayerHasLeft(zone, None)
}
+ case Zone.Bots.Spawn(bot) =>
+ if (BotSpawn(bot, botList)) {
+ bot.Zone = zone
+ zone.actor ! ZoneActor.AddToBlockMap(bot, bot.Position)
+ zone.AvatarEvents ! AvatarServiceMessage(
+ zone.id,
+ AvatarAction.LoadPlayer(bot.GUID, bot.Definition.ObjectId, bot.GUID, bot.Definition.Packet.ConstructorData(bot).get, None)
+ )
+ }
+
+ case Zone.Bots.Release(bot) =>
+ if (BotRelease(bot, botList)) {
+ if (bot.Actor != null) bot.Actor ! akka.actor.PoisonPill
+ bot.Actor = Default.Actor
+ zone.actor ! ZoneActor.RemoveFromBlockMap(bot)
+ zone.AvatarEvents ! AvatarServiceMessage(
+ zone.id,
+ AvatarAction.ObjectDelete(bot.GUID, bot.GUID, unk=0)
+ )
+ }
+
case Zone.Corpse.Add(player) =>
//player can be a corpse if they are in the current zone or are not in any zone
//player is "found" if their avatar can be matched by name within this zone and it has a character
@@ -202,6 +224,31 @@ object ZonePopulationActor {
}
}
+ /**
+ * Add the given `AvatarBot` to the list of bots.
+ * @param bot an `AvatarBot` object
+ * @param botList a list of `AvatarBot` objects
+ */
+ def BotSpawn(bot: AvatarBot, botList: ListBuffer[AvatarBot]): Boolean = {
+ botList += bot
+ true
+ }
+
+ /**
+ * Remove the given `AvatarBot` from the list of bots.
+ * @param bot an `AvatarBot` object
+ * @param botList a list of `AvatarBot` objects
+ */
+ def BotRelease(bot: AvatarBot, botList: ListBuffer[AvatarBot]): Boolean = {
+ botList.indexOf(bot) match {
+ case -1 =>
+ false
+ case index =>
+ botList.remove(index)
+ true
+ }
+ }
+
/**
* If the given `player` passes a condition check, add it to the list.
* @param player a `Player` object
diff --git a/src/main/scala/net/psforever/objects/zones/blockmap/Sector.scala b/src/main/scala/net/psforever/objects/zones/blockmap/Sector.scala
index cbaf923cb..8e8b8ba95 100644
--- a/src/main/scala/net/psforever/objects/zones/blockmap/Sector.scala
+++ b/src/main/scala/net/psforever/objects/zones/blockmap/Sector.scala
@@ -7,6 +7,7 @@ import net.psforever.objects.equipment.Equipment
import net.psforever.objects.serverobject.environment.PieceOfEnvironment
import net.psforever.objects.serverobject.structures.{Amenity, Building}
import net.psforever.objects.{Player, Vehicle}
+import net.psforever.objects.avatar.AvatarBot
import scala.collection.mutable.ListBuffer
@@ -20,6 +21,8 @@ trait SectorPopulation {
def livePlayerList: List[Player]
+ def botList: List[AvatarBot]
+
def corpseList: List[Player]
def vehicleList: List[Vehicle]
@@ -41,6 +44,7 @@ trait SectorPopulation {
*/
def total: Int = {
livePlayerList.size +
+ botList.size +
corpseList.size +
vehicleList.size +
equipmentOnGroundList.size +
@@ -123,6 +127,10 @@ class Sector(val longitude: Int, val latitude: Int, val span: Int)
(a: Player, b: Player) => a.GUID == b.GUID
)
+ private val bots: SectorListOf[AvatarBot] = new SectorListOf[AvatarBot](
+ (a: AvatarBot, b: AvatarBot) => a.GUID == b.GUID
+ )
+
private val corpses: SectorListOf[Player] = new SectorListOf[Player](
(a: Player, b: Player) => a eq b
)
@@ -161,6 +169,8 @@ class Sector(val longitude: Int, val latitude: Int, val span: Int)
def livePlayerList : List[Player] = livePlayers.list
+ def botList : List[AvatarBot] = bots.list
+
def corpseList: List[Player] = corpses.list
def vehicleList: List[Vehicle] = vehicles.list
@@ -191,6 +201,8 @@ class Sector(val longitude: Int, val latitude: Int, val span: Int)
corpses.list.size < corpses.addTo(p).size
case p: Player =>
livePlayers.list.size < livePlayers.addTo(p).size
+ case ab: AvatarBot =>
+ bots.list.size < bots.addTo(ab).size
case v: Vehicle =>
vehicles.list.size < vehicles.addTo(v).size
case e: Equipment =>
@@ -226,6 +238,8 @@ class Sector(val longitude: Int, val latitude: Int, val span: Int)
corpses.list.size > corpses.removeFrom(p).size
case p: Player =>
livePlayers.list.size > livePlayers.removeFrom(p).size
+ case ab: AvatarBot =>
+ bots.list.size > bots.removeFrom(ab).size
case v: Vehicle =>
vehicles.list.size > vehicles.removeFrom(v).size
case e: Equipment =>
@@ -253,6 +267,7 @@ object Sector {
* The specific datastructure that is mentioned when using the term "sector conglomerate".
* Typically used to compose the lists of entities from various individual sectors.
* @param livePlayerList the living players
+ * @param botList the bot avatars
* @param corpseList the dead players
* @param vehicleList vehicles
* @param equipmentOnGroundList dropped equipment
@@ -265,6 +280,7 @@ class SectorGroup(
val rangeX: Float,
val rangeY: Float,
val livePlayerList: List[Player],
+ val botList: List[AvatarBot],
val corpseList: List[Player],
val vehicleList: List[Vehicle],
val equipmentOnGroundList: List[Equipment],
@@ -314,6 +330,7 @@ object SectorGroup {
rangeX,
rangeY,
sector.livePlayerList.filterNot(p => p.spectator || !p.allowInteraction),
+ sector.botList.filterNot(b => !b.allowInteraction),
sector.corpseList,
sector.vehicleList,
sector.equipmentOnGroundList,
@@ -362,13 +379,14 @@ object SectorGroup {
*/
def apply(rangeX: Float, rangeY: Float, sectors: Iterable[Sector]): SectorGroup = {
if (sectors.isEmpty) {
- new SectorGroup(rangeX, rangeY, Nil, Nil, Nil, Nil, Nil, Nil, Nil, Nil, Nil)
+ new SectorGroup(rangeX, rangeY, Nil, Nil, Nil, Nil, Nil, Nil, Nil, Nil, Nil, Nil)
} else if (sectors.size == 1) {
val sector = sectors.head
new SectorGroup(
rangeX,
rangeY,
sector.livePlayerList.filterNot(p => p.spectator || !p.allowInteraction),
+ sector.botList.filterNot(b => !b.allowInteraction),
sector.corpseList,
sector.vehicleList,
sector.equipmentOnGroundList,
@@ -383,6 +401,7 @@ object SectorGroup {
rangeX,
rangeY,
sectors.flatMap { _.livePlayerList }.toList.distinct.filterNot(p => p.spectator || !p.allowInteraction),
+ sectors.flatMap { _.botList }.toList.distinct.filterNot(b => !b.allowInteraction),
sectors.flatMap { _.corpseList }.toList.distinct,
sectors.flatMap { _.vehicleList }.toList.distinct,
sectors.flatMap { _.equipmentOnGroundList }.toList.distinct,
diff --git a/src/main/scala/net/psforever/packet/game/GenericActionMessage.scala b/src/main/scala/net/psforever/packet/game/GenericActionMessage.scala
index 1a50426ce..1d89fbd66 100644
--- a/src/main/scala/net/psforever/packet/game/GenericActionMessage.scala
+++ b/src/main/scala/net/psforever/packet/game/GenericActionMessage.scala
@@ -38,6 +38,7 @@ object GenericAction extends IntEnum[GenericAction] {
final case object FailToDeconstruct extends GenericAction(value = 33)
final case object LookingForSquad_RCV extends GenericAction(value = 36)
final case object NotLookingForSquad_RCV extends GenericAction(value = 37)
+ final case object TrainingGriefWarning extends GenericAction(value = 43)
final case object Unknown45 extends GenericAction(value = 45)
final case class Unknown(override val value: Int) extends GenericAction(value)
diff --git a/src/main/scala/net/psforever/packet/game/TriggerBotAction.scala b/src/main/scala/net/psforever/packet/game/TriggerBotAction.scala
new file mode 100644
index 000000000..83543d912
--- /dev/null
+++ b/src/main/scala/net/psforever/packet/game/TriggerBotAction.scala
@@ -0,0 +1,27 @@
+// Copyright (c) 2026 PSForever
+package net.psforever.packet.game
+
+import net.psforever.packet.{GamePacketOpcode, Marshallable, PlanetSideGamePacket}
+import net.psforever.types.PlanetSideGUID
+import scodec.Codec
+import scodec.codecs._
+
+/**
+ * Dispatched from the server to cause a bot avatar to perform a random emote
+ * @param bot_guid the target bot's global unique identifier
+ */
+final case class TriggerBotAction(bot_guid: PlanetSideGUID, unk1: Long = 0, unk2: Long = 0, unk3: Long = 4294967295L)
+ extends PlanetSideGamePacket {
+ type Packet = TriggerBotAction
+ def opcode = GamePacketOpcode.TriggerBotAction
+ def encode = TriggerBotAction.encode(this)
+}
+
+object TriggerBotAction extends Marshallable[TriggerBotAction] {
+ implicit val codec : Codec[TriggerBotAction] = (
+ ("bot_guid" | PlanetSideGUID.codec) ::
+ ("unk1" | uint32L) ::
+ ("unk2" | uint32L) ::
+ ("unk3" | uint32L)
+ ).as[TriggerBotAction]
+}
diff --git a/src/main/scala/net/psforever/packet/game/objectcreate/ObjectClass.scala b/src/main/scala/net/psforever/packet/game/objectcreate/ObjectClass.scala
index 481cd014b..9e78796a1 100644
--- a/src/main/scala/net/psforever/packet/game/objectcreate/ObjectClass.scala
+++ b/src/main/scala/net/psforever/packet/game/objectcreate/ObjectClass.scala
@@ -404,28 +404,37 @@ object ObjectClass {
final val vulture = 986
final val wasp = 997
//other
- final val ams_order_terminal = 48
- final val ams_respawn_tube = 49
- final val avatar = 121
- final val bfr_rearm_terminal = 142
- final val capture_flag = 157
- final val implant_terminal_interface = 409
- final val locker_container = 456
- final val lodestar_repair_terminal = 461
- final val manned_turret = 480
- final val matrix_terminala = 517
- final val matrix_terminalb = 518
- final val matrix_terminalc = 519
- final val multivehicle_rearm_terminal = 576
- final val order_terminal = 612
- final val order_terminala = 613
- final val order_terminalb = 614
- final val portable_ammo_terminal = 684
- final val portable_order_terminal = 690
- final val spawn_pad = 800
- final val spawn_zone = 815
- final val targeting_laser_dispenser = 851
- final val teleportpad_terminal = 853
+ final val ams_order_terminal = 48
+ final val ams_respawn_tube = 49
+ final val avatar = 121
+ final val avatar_bot = 122
+ final val avatar_bot_agile = 123
+ final val avatar_bot_agile_no_weapon = 124
+ final val avatar_bot_max = 125
+ final val avatar_bot_max_no_weapon = 126
+ final val avatar_bot_reinforced = 127
+ final val avatar_bot_reinforced_no_weapon = 128
+ final val avatar_bot_standard = 129
+ final val avatar_bot_standard_no_weapon = 130
+ final val bfr_rearm_terminal = 142
+ final val capture_flag = 157
+ final val implant_terminal_interface = 409
+ final val locker_container = 456
+ final val lodestar_repair_terminal = 461
+ final val manned_turret = 480
+ final val matrix_terminala = 517
+ final val matrix_terminalb = 518
+ final val matrix_terminalc = 519
+ final val multivehicle_rearm_terminal = 576
+ final val order_terminal = 612
+ final val order_terminala = 613
+ final val order_terminalb = 614
+ final val portable_ammo_terminal = 684
+ final val portable_order_terminal = 690
+ final val spawn_pad = 800
+ final val spawn_zone = 815
+ final val targeting_laser_dispenser = 851
+ final val teleportpad_terminal = 853
// For property overrides
final val delivererv = 239
@@ -664,8 +673,17 @@ object ObjectClass {
case ObjectClass.router_telepad => ConstructorData(DetailedConstructionToolData.codec, "router telepad")
case ObjectClass.boomer_trigger => ConstructorData(DetailedConstructionToolData.codec, "boomer trigger")
//other
- case ObjectClass.avatar => ConstructorData(DetailedPlayerData.codec(false), "avatar")
- case ObjectClass.locker_container => ConstructorData(DetailedLockerContainerData.codec, "locker container")
+ case ObjectClass.avatar => ConstructorData(DetailedPlayerData.codec(false), "avatar")
+ case ObjectClass.avatar_bot => ConstructorData(DetailedPlayerData.codec(false), "avatar bot")
+ case ObjectClass.avatar_bot_agile => ConstructorData(DetailedPlayerData.codec(false), "avatar bot agile")
+ case ObjectClass.avatar_bot_agile_no_weapon => ConstructorData(DetailedPlayerData.codec(false), "avatar bot agile no weapon")
+ case ObjectClass.avatar_bot_max => ConstructorData(DetailedPlayerData.codec(false), "avatar bot max")
+ case ObjectClass.avatar_bot_max_no_weapon => ConstructorData(DetailedPlayerData.codec(false), "avatar bot max no weapon")
+ case ObjectClass.avatar_bot_reinforced => ConstructorData(DetailedPlayerData.codec(false), "avatar bot reinforced")
+ case ObjectClass.avatar_bot_reinforced_no_weapon => ConstructorData(DetailedPlayerData.codec(false), "avatar bot reinforced no weapon")
+ case ObjectClass.avatar_bot_standard => ConstructorData(DetailedPlayerData.codec(false), "avatar bot standard")
+ case ObjectClass.avatar_bot_standard_no_weapon => ConstructorData(DetailedPlayerData.codec(false), "avatar bot standard no weapon")
+ case ObjectClass.locker_container => ConstructorData(DetailedLockerContainerData.codec, "locker container")
//failure case
case _ => defaultFailureCodec(objClass)
@@ -983,21 +1001,30 @@ object ObjectClass {
//vehicles?
case ObjectClass.orbital_shuttle => ConstructorData(OrbitalShuttleData.codec, "HART")
//other
- case ObjectClass.ams_order_terminal => ConstructorData(CommonFieldData.codec2, "terminal")
- case ObjectClass.ams_respawn_tube => ConstructorData(CommonFieldData.codec2, "terminal")
- case ObjectClass.avatar => ConstructorData(PlayerData.codec(false), "avatar")
- case ObjectClass.bfr_rearm_terminal => ConstructorData(CommonFieldData.codec2, "terminal")
- case ObjectClass.implant_terminal_interface => ConstructorData(CommonFieldData.codec2, "implant terminal")
- case ObjectClass.lodestar_repair_terminal => ConstructorData(CommonFieldData.codec2, "terminal")
- case ObjectClass.matrix_terminala => ConstructorData(CommonFieldData.codec2, "terminal")
- case ObjectClass.matrix_terminalb => ConstructorData(CommonFieldData.codec2, "terminal")
- case ObjectClass.matrix_terminalc => ConstructorData(CommonFieldData.codec2, "terminal")
- case ObjectClass.multivehicle_rearm_terminal => ConstructorData(CommonFieldData.codec2, "terminal")
- case ObjectClass.order_terminal => ConstructorData(CommonFieldData.codec2, "terminal")
- case ObjectClass.order_terminala => ConstructorData(CommonFieldData.codec2, "terminal")
- case ObjectClass.order_terminalb => ConstructorData(CommonFieldData.codec2, "terminal")
- case ObjectClass.targeting_laser_dispenser => ConstructorData(CommonFieldData.codec2, "terminal")
- case ObjectClass.teleportpad_terminal => ConstructorData(CommonFieldData.codec2, "terminal")
+ case ObjectClass.ams_order_terminal => ConstructorData(CommonFieldData.codec2, "terminal")
+ case ObjectClass.ams_respawn_tube => ConstructorData(CommonFieldData.codec2, "terminal")
+ case ObjectClass.avatar => ConstructorData(PlayerData.codec(false), "avatar")
+ case ObjectClass.avatar_bot => ConstructorData(PlayerData.codec(false), "avatar bot")
+ case ObjectClass.avatar_bot_agile => ConstructorData(PlayerData.codec(false), "avatar bot agile")
+ case ObjectClass.avatar_bot_agile_no_weapon => ConstructorData(PlayerData.codec(false), "avatar bot agile no weapon")
+ case ObjectClass.avatar_bot_max => ConstructorData(PlayerData.codec(false), "avatar bot max")
+ case ObjectClass.avatar_bot_max_no_weapon => ConstructorData(PlayerData.codec(false), "avatar bot max no weapon")
+ case ObjectClass.avatar_bot_reinforced => ConstructorData(PlayerData.codec(false), "avatar bot reinforced")
+ case ObjectClass.avatar_bot_reinforced_no_weapon => ConstructorData(PlayerData.codec(false), "avatar bot reinforced no weapon")
+ case ObjectClass.avatar_bot_standard => ConstructorData(PlayerData.codec(false), "avatar bot standard")
+ case ObjectClass.avatar_bot_standard_no_weapon => ConstructorData(PlayerData.codec(false), "avatar bot standard no weapon")
+ case ObjectClass.bfr_rearm_terminal => ConstructorData(CommonFieldData.codec2, "terminal")
+ case ObjectClass.implant_terminal_interface => ConstructorData(CommonFieldData.codec2, "implant terminal")
+ case ObjectClass.lodestar_repair_terminal => ConstructorData(CommonFieldData.codec2, "terminal")
+ case ObjectClass.matrix_terminala => ConstructorData(CommonFieldData.codec2, "terminal")
+ case ObjectClass.matrix_terminalb => ConstructorData(CommonFieldData.codec2, "terminal")
+ case ObjectClass.matrix_terminalc => ConstructorData(CommonFieldData.codec2, "terminal")
+ case ObjectClass.multivehicle_rearm_terminal => ConstructorData(CommonFieldData.codec2, "terminal")
+ case ObjectClass.order_terminal => ConstructorData(CommonFieldData.codec2, "terminal")
+ case ObjectClass.order_terminala => ConstructorData(CommonFieldData.codec2, "terminal")
+ case ObjectClass.order_terminalb => ConstructorData(CommonFieldData.codec2, "terminal")
+ case ObjectClass.targeting_laser_dispenser => ConstructorData(CommonFieldData.codec2, "terminal")
+ case ObjectClass.teleportpad_terminal => ConstructorData(CommonFieldData.codec2, "terminal")
//failure case
case _ => defaultFailureCodec(objClass)
}
@@ -1324,17 +1351,26 @@ object ObjectClass {
case ObjectClass.vulture => ConstructorData(VehicleData.codec(VehicleFormat.Variant), "vehicle")
case ObjectClass.wasp => ConstructorData(VehicleData.codec(VehicleFormat.Variant), "vehicle")
//other
- case ObjectClass.ams_respawn_tube => DroppedItemData(CommonFieldData.codec2, "terminal")
- case ObjectClass.avatar => ConstructorData(PlayerData.codec(true), "avatar")
- case ObjectClass.capture_flag => ConstructorData(CaptureFlagData.codec, "capture flag")
- case ObjectClass.implant_terminal_interface => ConstructorData(CommonFieldData.codec2, "implant terminal")
- case ObjectClass.locker_container => ConstructorData(LockerContainerData.codec, "locker container")
- case ObjectClass.matrix_terminala => DroppedItemData(CommonFieldData.codec2, "terminal")
- case ObjectClass.matrix_terminalb => DroppedItemData(CommonFieldData.codec2, "terminal")
- case ObjectClass.matrix_terminalc => DroppedItemData(CommonFieldData.codec2, "terminal")
- case ObjectClass.order_terminal => DroppedItemData(CommonFieldData.codec2, "terminal")
- case ObjectClass.order_terminala => DroppedItemData(CommonFieldData.codec2, "terminal")
- case ObjectClass.order_terminalb => DroppedItemData(CommonFieldData.codec2, "terminal")
+ case ObjectClass.ams_respawn_tube => DroppedItemData(CommonFieldData.codec2, "terminal")
+ case ObjectClass.avatar => ConstructorData(PlayerData.codec(true), "avatar")
+ case ObjectClass.avatar_bot => ConstructorData(PlayerData.codec(true), "avatar bot")
+ case ObjectClass.avatar_bot_agile => ConstructorData(PlayerData.codec(true), "avatar bot agile")
+ case ObjectClass.avatar_bot_agile_no_weapon => ConstructorData(PlayerData.codec(true), "avatar bot agile no weapon")
+ case ObjectClass.avatar_bot_max => ConstructorData(PlayerData.codec(true), "avatar bot max")
+ case ObjectClass.avatar_bot_max_no_weapon => ConstructorData(PlayerData.codec(true), "avatar bot max no weapon")
+ case ObjectClass.avatar_bot_reinforced => ConstructorData(PlayerData.codec(true), "avatar bot reinforced")
+ case ObjectClass.avatar_bot_reinforced_no_weapon => ConstructorData(PlayerData.codec(true), "avatar bot reinforced no weapon")
+ case ObjectClass.avatar_bot_standard => ConstructorData(PlayerData.codec(true), "avatar bot standard")
+ case ObjectClass.avatar_bot_standard_no_weapon => ConstructorData(PlayerData.codec(true), "avatar bot standard no weapon")
+ case ObjectClass.capture_flag => ConstructorData(CaptureFlagData.codec, "capture flag")
+ case ObjectClass.implant_terminal_interface => ConstructorData(CommonFieldData.codec2, "implant terminal")
+ case ObjectClass.locker_container => ConstructorData(LockerContainerData.codec, "locker container")
+ case ObjectClass.matrix_terminala => DroppedItemData(CommonFieldData.codec2, "terminal")
+ case ObjectClass.matrix_terminalb => DroppedItemData(CommonFieldData.codec2, "terminal")
+ case ObjectClass.matrix_terminalc => DroppedItemData(CommonFieldData.codec2, "terminal")
+ case ObjectClass.order_terminal => DroppedItemData(CommonFieldData.codec2, "terminal")
+ case ObjectClass.order_terminala => DroppedItemData(CommonFieldData.codec2, "terminal")
+ case ObjectClass.order_terminalb => DroppedItemData(CommonFieldData.codec2, "terminal")
//failure case
case _ => defaultFailureCodec(objClass)
}
diff --git a/src/main/scala/net/psforever/util/Config.scala b/src/main/scala/net/psforever/util/Config.scala
index ef1314248..c814904b6 100644
--- a/src/main/scala/net/psforever/util/Config.scala
+++ b/src/main/scala/net/psforever/util/Config.scala
@@ -158,6 +158,7 @@ case class GameConfig(
baseCertifications: Seq[Certification],
warpGates: WarpGateConfig,
cavernRotation: CavernRotationConfig,
+ virtualTraining: VirtualTrainingConfig,
savedMsg: SavedMessageEvents,
playerDraw: PlayerStateDrawSettings,
doorsCanBeOpenedByMedAppFromThisDistance: Float,
@@ -218,6 +219,13 @@ case class CavernRotationConfig(
forceRotationImmediately: Boolean
)
+case class VirtualTrainingConfig(
+ shootingRangeTargetsEnabled: Boolean,
+ maleBotNames: List[String],
+ femaleBotNames: List[String],
+ universalBotNames: List[String]
+)
+
case class SavedMessageEvents(
short: SavedMessageTimings,
renewal: SavedMessageTimings,