This commit is contained in:
Subsonic154 2026-05-08 16:47:30 +00:00 committed by GitHub
commit 88b5bbe2b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 2199 additions and 130 deletions

View file

@ -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.

View file

@ -0,0 +1,14 @@
[
{
"Name": "environment",
"Start": 0,
"Max": 896,
"Selector": "specific"
},
{
"Name": "bots",
"Start": 1300,
"Max": 1600,
"Selector": "random"
}
]

View file

@ -0,0 +1,14 @@
[
{
"Name": "environment",
"Start": 0,
"Max": 896,
"Selector": "specific"
},
{
"Name": "bots",
"Start": 1300,
"Max": 1600,
"Selector": "random"
}
]

View file

@ -0,0 +1,14 @@
[
{
"Name": "environment",
"Start": 0,
"Max": 896,
"Selector": "specific"
},
{
"Name": "bots",
"Start": 1300,
"Max": 1600,
"Selector": "random"
}
]

View file

@ -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) =>

View file

@ -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) =>

View file

@ -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) =>

View file

@ -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 _ => ()
}

View file

@ -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) {

View file

@ -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)

View file

@ -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 =>
}
}
}

View file

@ -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
*/

View file

@ -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) }
}
}

View file

@ -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.<br>
* <br>
* 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.<br>
* <br>
* @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
}
}

View file

@ -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 =

View file

@ -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()
)
}
}
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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`.<br>
* <br>
* @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`.<br>
* <br>
@ -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.<br>
* <br>
* 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.<br>
* <br>

View file

@ -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

View file

@ -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) =>
(

View file

@ -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)
}

View file

@ -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)

View file

@ -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()

View file

@ -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)

View file

@ -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
}
/**

View file

@ -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

View file

@ -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,

View file

@ -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)

View file

@ -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]
}

View file

@ -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)
}

View file

@ -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,