From 10a647f080d973f5bafbd53d71abbe61f2e7d0c8 Mon Sep 17 00:00:00 2001 From: Subsonic154 Date: Mon, 4 May 2026 06:35:17 -0400 Subject: [PATCH 01/12] VR Shooting Range targets initial implementation + vehicle deconstruction fixes --- src/main/resources/application.conf | 16 +- src/main/resources/guid-pools/tzshnc.json | 14 + src/main/resources/guid-pools/tzshtr.json | 14 + src/main/resources/guid-pools/tzshvs.json | 14 + .../session/support/GeneralOperations.scala | 15 +- .../actors/session/support/SessionData.scala | 35 +- .../WeaponAndProjectileOperations.scala | 2 +- .../session/support/ZoningOperations.scala | 43 +- .../zone/ShootingRangeTargetSpawner.scala | 373 ++++++++++++++ .../psforever/objects/GlobalDefinitions.scala | 90 ++++ .../psforever/objects/avatar/AvatarBot.scala | 284 +++++++++++ .../objects/avatar/AvatarBotActor.scala | 480 ++++++++++++++++++ .../objects/avatar/PlayerControl.scala | 14 +- .../InteractWithRadiationClouds.scala | 33 +- .../definition/AvatarBotDefinition.scala | 33 ++ .../converter/AvatarBotConverter.scala | 306 +++++++++++ .../objects/equipment/EffectTarget.scala | 3 + .../objects/geometry/GeometryForm.scala | 8 + .../net/psforever/objects/guid/GUIDTask.scala | 29 ++ .../terminals/tabs/ExclusionRule.scala | 6 +- .../terminals/tabs/VehiclePage.scala | 2 +- .../objects/sourcing/PlayerSource.scala | 22 + .../objects/sourcing/SourceEntry.scala | 2 + .../vehicles/control/VehicleControl.scala | 30 +- .../resolution/ResolutionCalculations.scala | 47 +- .../net/psforever/objects/zones/Zone.scala | 58 ++- .../objects/zones/ZonePopulationActor.scala | 53 +- .../objects/zones/blockmap/Sector.scala | 21 +- .../packet/game/GenericActionMessage.scala | 1 + .../packet/game/TriggerBotAction.scala | 27 + .../game/objectcreate/ObjectClass.scala | 136 +++-- .../scala/net/psforever/util/Config.scala | 8 + 32 files changed, 2094 insertions(+), 125 deletions(-) create mode 100644 src/main/resources/guid-pools/tzshnc.json create mode 100644 src/main/resources/guid-pools/tzshtr.json create mode 100644 src/main/resources/guid-pools/tzshvs.json create mode 100644 src/main/scala/net/psforever/actors/zone/ShootingRangeTargetSpawner.scala create mode 100644 src/main/scala/net/psforever/objects/avatar/AvatarBot.scala create mode 100644 src/main/scala/net/psforever/objects/avatar/AvatarBotActor.scala create mode 100644 src/main/scala/net/psforever/objects/definition/AvatarBotDefinition.scala create mode 100644 src/main/scala/net/psforever/objects/definition/converter/AvatarBotConverter.scala create mode 100644 src/main/scala/net/psforever/packet/game/TriggerBotAction.scala diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index 20885b9e5..8aed67ed5 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -23,7 +23,7 @@ world { ports = [] # The name of the server as displayed in the server browser. - server-name = "\\#1EACF9P\\#E87BE8S\\#F93F4E4ever" + server-name = "vr-shooting-targets" # How the server is displayed in the server browser. # One of: released beta development @@ -154,6 +154,20 @@ game { force-rotation-immediately = false } + virtual-training = { + # Enables VR Shooting Range targets, if you know the VR Shooting Range is not going to be used, + # you can disable target spawns to slightly reduce the server's baseline CPU and memory usage. + shooting-range-targets-enabled = true + + # You can customize the list of VR Shooting Range bot names here. + # The default names are all names that were observed from the original servers. + male-bot-names = ["Aphtek", "Arrius", "Augustus", "Batezek", "Baulk", "Bayloon", "Bentar", "Braydon", "Brok", "Brutus", "Byatis", "Cantrip", "Doragon", "Elion", "Garbazhu", "GAxir", "Ghurmak", "Grailbait", "Graznack", "Gorabbus", "Gorganogan", "Hlanith", "Jitrick", "Julianus", "Knyan", "Lupus", "Marcus", "Morkardar", "Morungat", "Murch", "Nerick", "Nezcaro", "Plinius", "Prondus", "Publius", "Quillion", "Remus", "Secarr", "Shantak", "Shoggoth", "Shulogrem", "Taractus", "Tazok", "Thran", "Thremnir", "Urzican", "Zorach", "Yarblek", "Yith", "Yoth"] + + female-bot-names = ["Agatz", "Aggan", "Arham", "Borador", "Brrt", "Cahh", "Ceth", "Chazor", "Cherik", "Corax", "Cyaegha", "Cykranosh", "Dentarg", "Drask", "Fathrd", "Ghorak", "Grogdish", "Grotta", "Gorman", "Hurrbrrmn", "Hrrd", "Ithak", "Julius", "Kephnes", "Kherek", "Korros", "Kraa", "Kradak", "Kryle", "Marcius", "Melderthra", "Minx", "Mnar", "Nanak", "Ngranek", "Nir", "Nyariathotep", "Sharagar", "Sharrak", "Shkar", "Ssaad", "Ssikz", "Tarach", "Tarkacho", "Tassadar", "Thraa", "Thurraz", "Tyr", "Utz", "Whea", "Yakkaz", "Yarak", "Zadrak"] + + universal-bot-names = ["Bane", "Barzai", "BoacTreth", "Brazoragh", "Cicero", "Chamak", "Chrkss", "Cshsssh", "DylathLeen", "Gnomon", "GrizzleBok", "Hatheg", "Izch", "Jazzkkrt", "Mogor", "Myst", "Nero", "OothNarga", "Orrg", "Shadow", "TchoTcho", "Twilight", "Vthak", "Vulthoom", "Ychch", "Zar", "Zhar"] + } + saved-msg = { # A brief delay to the @charsaved message, in seconds. # Use this when the message should display very soon for any reason. diff --git a/src/main/resources/guid-pools/tzshnc.json b/src/main/resources/guid-pools/tzshnc.json new file mode 100644 index 000000000..657ed7965 --- /dev/null +++ b/src/main/resources/guid-pools/tzshnc.json @@ -0,0 +1,14 @@ +[ + { + "Name": "environment", + "Start": 0, + "Max": 896, + "Selector": "specific" + }, + { + "Name": "bots", + "Start": 1300, + "Max": 1600, + "Selector": "random" + } +] diff --git a/src/main/resources/guid-pools/tzshtr.json b/src/main/resources/guid-pools/tzshtr.json new file mode 100644 index 000000000..657ed7965 --- /dev/null +++ b/src/main/resources/guid-pools/tzshtr.json @@ -0,0 +1,14 @@ +[ + { + "Name": "environment", + "Start": 0, + "Max": 896, + "Selector": "specific" + }, + { + "Name": "bots", + "Start": 1300, + "Max": 1600, + "Selector": "random" + } +] diff --git a/src/main/resources/guid-pools/tzshvs.json b/src/main/resources/guid-pools/tzshvs.json new file mode 100644 index 000000000..657ed7965 --- /dev/null +++ b/src/main/resources/guid-pools/tzshvs.json @@ -0,0 +1,14 @@ +[ + { + "Name": "environment", + "Start": 0, + "Max": 896, + "Selector": "specific" + }, + { + "Name": "bots", + "Start": 1300, + "Max": 1600, + "Selector": "random" + } +] diff --git a/src/main/scala/net/psforever/actors/session/support/GeneralOperations.scala b/src/main/scala/net/psforever/actors/session/support/GeneralOperations.scala index a9692605a..3d2a92e95 100644 --- a/src/main/scala/net/psforever/actors/session/support/GeneralOperations.scala +++ b/src/main/scala/net/psforever/actors/session/support/GeneralOperations.scala @@ -2,6 +2,7 @@ package net.psforever.actors.session.support import akka.actor.{ActorContext, ActorRef, Cancellable, typed} +import net.psforever.objects.avatar.AvatarBot import net.psforever.objects.serverobject.containable.Containable import net.psforever.objects.serverobject.doors.Door import net.psforever.objects.serverobject.interior.Sidedness @@ -393,9 +394,17 @@ class GeneralOperations( 0 } Some(TargetInfo(player.GUID, health, armor)) + case Some(bot: AvatarBot) => + val health = bot.Health.toFloat / bot.MaxHealth + val armor = if (bot.MaxArmor > 0) { + bot.Armor.toFloat / bot.MaxArmor + } else { + 0 + } + Some(TargetInfo(bot.GUID, health, armor)) case _ => log.warn( - s"TargetingImplantRequest: the info that ${player.Name} requested for target ${x.target_guid} is not for a player" + s"TargetingImplantRequest: the info that ${player.Name} requested for target ${x.target_guid} is not for a player or bot" ) None } @@ -1060,9 +1069,7 @@ class GeneralOperations( } def trainingGriefWarning(): Unit = { - //don't know the correct ChatMessageType for this one yet; - //it's supposed to use a pop-up that takes 5 seconds before you are allowed to close it, so disabled for now - //sendResponse(ChatMsg(ChatMessageType.CMT_QUIT, wideContents=true, "", "@TrainingGriefWarning", None)) + sendResponse(GenericActionMessage(GenericAction.TrainingGriefWarning)) } def noVoicedChat(pkt: PlanetSideGamePacket): Unit = { diff --git a/src/main/scala/net/psforever/actors/session/support/SessionData.scala b/src/main/scala/net/psforever/actors/session/support/SessionData.scala index 09a6e6725..3af1b649b 100644 --- a/src/main/scala/net/psforever/actors/session/support/SessionData.scala +++ b/src/main/scala/net/psforever/actors/session/support/SessionData.scala @@ -422,14 +422,25 @@ class SessionData( if (obj.spectator && obj != player) { administrativeKick(player) } else { - if (obj.IsInVRZone && obj.Faction == player.Faction && obj.CharId != player.CharId) { - //don't do friendly-fire in VR zones - general.trainingGriefWarning() + if (obj.IsInVRZone && obj.Faction == player.Faction) { + //disable self-damage and friendly-fire in VR zones + if (obj.CharId != player.CharId) { + general.trainingGriefWarning() + } } else { obj.Actor ! Vitality.Damage(func) } } + case obj: AvatarBot if obj.CanDamage && obj.Actor != Default.Actor => + log.info(s"${player.Name} is attacking ${obj.Name}") + if (obj.IsInVRZone && obj.Faction == player.Faction) { + //disable friendly-fire in VR zones + general.trainingGriefWarning() + } else { + obj.Actor ! Vitality.Damage(func) + } + case obj: Vehicle if obj.CanDamage => val name = player.Name val ownerName = obj.OwnerName.getOrElse("someone") @@ -438,16 +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 + //disable friendly-fire in VR zones general.trainingGriefWarning() } else { obj.Actor ! Vitality.Damage(func) @@ -461,9 +474,11 @@ 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 friendly-fire in VR zones + if (!ownerName.equals(name)) { + general.trainingGriefWarning() + } } else { obj.Actor ! Vitality.Damage(func) } diff --git a/src/main/scala/net/psforever/actors/session/support/WeaponAndProjectileOperations.scala b/src/main/scala/net/psforever/actors/session/support/WeaponAndProjectileOperations.scala index 2e32d426a..987331c4c 100644 --- a/src/main/scala/net/psforever/actors/session/support/WeaponAndProjectileOperations.scala +++ b/src/main/scala/net/psforever/actors/session/support/WeaponAndProjectileOperations.scala @@ -725,7 +725,7 @@ class WeaponAndProjectileOperations( proxy.Position = hitPos proxy.WhichSide = Sidedness.StrictlyBetweenSides val radiusSquared = proxy.profile.LashRadius * proxy.profile.LashRadius - var availableTargets = sessionLogic.localSector.livePlayerList + var availableTargets = sessionLogic.localSector.livePlayerList ++ sessionLogic.localSector.botList var unresolvedChainLashHits: Seq[VolumetricGeometry] = Seq(Point(hitPos)) var uniqueChainLashTargets: Seq[(PlanetSideGameObject with FactionAffinity with Vitality, Projectile)] = Seq() while (unresolvedChainLashHits.nonEmpty) { diff --git a/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala b/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala index bd63e19f8..12f80e1fc 100644 --- a/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala +++ b/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala @@ -352,6 +352,9 @@ class ZoningOperations( .foreach { targetPlayer => sendResponse(PlanetsideAttributeMessage(targetPlayer.GUID, 1, 120)) } + //load active bots in zone + val bots = continent.BotAvatars + bots.foreach(bot => sendResponse(OCM.apply(bot))) //load corpses in zone continent.Corpses.foreach { spawn.DepictPlayerAsCorpse @@ -887,30 +890,18 @@ class ZoningOperations( RequestSanctuaryZoneSpawn(player, player.Zone.Number) // leveraging SetZone for now because the VR zones have no "proper" spawn points configured yet case 17 => - if (player.Zone.id != "tzshtr") { - context.self ! SessionActor.SetZone("tzshtr", vrShootingZoneSpawns.toArray.apply(rand.nextInt(vrShootingZoneSpawns.size))) - } + context.self ! SessionActor.SetZone("tzshtr", vrShootingZoneSpawns.toArray.apply(rand.nextInt(vrShootingZoneSpawns.size))) case 18 => - if (player.Zone.id != "tzshnc") { - context.self ! SessionActor.SetZone("tzshnc", vrShootingZoneSpawns.toArray.apply(rand.nextInt(vrShootingZoneSpawns.size))) - } + context.self ! SessionActor.SetZone("tzshnc", vrShootingZoneSpawns.toArray.apply(rand.nextInt(vrShootingZoneSpawns.size))) case 19 => - if (player.Zone.id != "tzshvs") { - context.self ! SessionActor.SetZone("tzshvs", vrShootingZoneSpawns.toArray.apply(rand.nextInt(vrShootingZoneSpawns.size))) - } + context.self ! SessionActor.SetZone("tzshvs", vrShootingZoneSpawns.toArray.apply(rand.nextInt(vrShootingZoneSpawns.size))) case 20 => - if (player.Zone.id != "tzdrtr") { - context.self ! SessionActor.SetZone("tzdrtr", vrDrivingAreaSpawns.toArray.apply(rand.nextInt(vrDrivingAreaSpawns.size))) - } + context.self ! SessionActor.SetZone("tzdrtr", vrDrivingAreaSpawns.toArray.apply(rand.nextInt(vrDrivingAreaSpawns.size))) case 21 => - if (player.Zone.id != "tzdrnc") { - context.self ! SessionActor.SetZone("tzdrnc", vrDrivingAreaSpawns.toArray.apply(rand.nextInt(vrDrivingAreaSpawns.size))) - } + context.self ! SessionActor.SetZone("tzdrnc", vrDrivingAreaSpawns.toArray.apply(rand.nextInt(vrDrivingAreaSpawns.size))) case 22 => - if (player.Zone.id != "tzdrvs") { - context.self ! SessionActor.SetZone("tzdrvs", vrDrivingAreaSpawns.toArray.apply(rand.nextInt(vrDrivingAreaSpawns.size))) - } - case _ => + context.self ! SessionActor.SetZone("tzdrvs", vrDrivingAreaSpawns.toArray.apply(rand.nextInt(vrDrivingAreaSpawns.size))) + case _ => log.warn(s"Received TrainingZoneMessage that requests unexpected zone number ${pkt.zone.guid}?") } } @@ -1280,9 +1271,11 @@ class ZoningOperations( ICS.FindZone(_.id.equals(zoneId), context.self) )) } else if (player.HasGUID) { - if (player.IsInVRZone && !zoneId.startsWith("tz")) { - // reset the player to default gear to prevent them from smuggling things out of VR - log.info(s"${player.Name} is exiting VR Training, resetting ${player.Sex.possessive} loadout") + if (zoneId.startsWith("tz") || player.IsInVRZone) { + // reset the players loadout when entering or exiting any VR Training zone + // this is to prevent both entering the VR Driving Area with an ExoSuit too heavy to drive, + // or smuggling special equipment out of the VR Shooting Range + log.info(s"${player.Name} is zoning to or from a VR Training zone, resetting ${player.Sex.possessive} loadout") val newPlayer = Player.Respawn(player) DefinitionUtil.applyDefaultLoadout(newPlayer) session = session.copy(player = newPlayer) @@ -3176,10 +3169,12 @@ class ZoningOperations( case _ if player.HasGUID => // player is deconstructing self or instant action val player_guid = player.GUID - sendResponse(ObjectDeleteMessage(player_guid, unk1=1)) + // entering or exiting VR zones uses a fade-out effect for the player instead of the usual green cloud deconstruction effect + val effect = if (player.IsInVRZone || zoneId.startsWith("tz")) 2 else 1 + sendResponse(ObjectDeleteMessage(player_guid, unk1=effect)) continent.AvatarEvents ! AvatarServiceMessage( continent.id, - AvatarAction.ObjectDelete(player_guid, player_guid, unk=1) + AvatarAction.ObjectDelete(player_guid, player_guid, unk=effect) ) InGameHistory.SpawnReconstructionActivity(player, toZoneNumber, betterSpawnPoint) LoadZoneAsPlayerUsing(player, pos, ori, toSide, zoneId) diff --git a/src/main/scala/net/psforever/actors/zone/ShootingRangeTargetSpawner.scala b/src/main/scala/net/psforever/actors/zone/ShootingRangeTargetSpawner.scala new file mode 100644 index 000000000..790062c4a --- /dev/null +++ b/src/main/scala/net/psforever/actors/zone/ShootingRangeTargetSpawner.scala @@ -0,0 +1,373 @@ +// 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, position: Vector3) +} + +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]() + private val botNamesInUse = ListBuffer[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.clear() + activeVehicleTargets.foreach{ target => + 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, pos) => + OnVehicleTargetDeconstructed(vehicle, pos) + + 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, yaw, false)} + infantrySpawnsMAX.foreach{case (pos, yaw) => CreateInfantryTarget(pos, yaw, 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, facingYaw: Float, 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 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 = { + 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.addOne(name) + name + } else { + log.warn(s"Male bot name pool in ${zone.id} is empty!") + "Undefined" + } + case CharacterSex.Female => + val availableNames = (femaleOnlyBotNames ++ universalBotNames).filterNot(n => botNamesInUse.contains(n)) + if (!availableNames.isEmpty) { + val name = availableNames(Random.nextInt(availableNames.size)) + botNamesInUse.addOne(name) + name + } else { + log.warn(s"Female bot name pool in ${zone.id} is empty!") + "Undefined" + } + } + } + + /** + * 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.Orientation.z, 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.remove(index) + } + 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) + 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, position: Vector3): Unit = { + activeVehicleTargets.indexOf(vehicle) match { + case -1 => + case index => + activeVehicleTargets.remove(index) + context.system.scheduler.scheduleOnce( + 5.seconds, + new Runnable() { override def run(): Unit = CreateVehicleTarget(position, vehicle.Orientation.z, GlobalDefinitions.isFlightVehicle(vehicle.Definition)) } + ) + } + } +} diff --git a/src/main/scala/net/psforever/objects/GlobalDefinitions.scala b/src/main/scala/net/psforever/objects/GlobalDefinitions.scala index b9b99181a..27ebd76fc 100644 --- a/src/main/scala/net/psforever/objects/GlobalDefinitions.scala +++ b/src/main/scala/net/psforever/objects/GlobalDefinitions.scala @@ -47,6 +47,96 @@ object GlobalDefinitions { avatar.collision.z = CollisionZData(Array((0.1f, 0), (5f, 1), (10f, 3), (20f, 5), (35f, 7), (50f, 10), (75f, 40), (100f, 100))) //not in the ADB avatar.maxForwardSpeed = 27f //not in the ADB; running speed + val avatar_bot = new AvatarBotDefinition(122) + avatar_bot.MaxHealth = 100 + avatar_bot.Damageable = true + avatar_bot.DrownAtMaxDepth = true + avatar_bot.MaxDepth = 1.609375f //Male, standing, not MAX + avatar_bot.UnderwaterLifespan(suffocation = 60000L, recovery = 10000L) + avatar_bot.collision.xy = CollisionXYData(Array((0.1f, 0), (0.2f, 5), (0.50f, 15), (0.75f, 20), (1f, 30))) //not in the ADB + avatar_bot.collision.z = CollisionZData(Array((0.1f, 0), (5f, 1), (10f, 3), (20f, 5), (35f, 7), (50f, 10), (75f, 40), (100f, 100))) //not in the ADB + avatar_bot.maxForwardSpeed = 27f //not in the ADB; running speed + + val avatar_bot_agile = new AvatarBotDefinition(123) + avatar_bot_agile.MaxHealth = 100 + avatar_bot_agile.Damageable = true + avatar_bot_agile.DrownAtMaxDepth = true + avatar_bot_agile.MaxDepth = 1.609375f //Male, standing, not MAX + avatar_bot_agile.UnderwaterLifespan(suffocation = 60000L, recovery = 10000L) + avatar_bot_agile.collision.xy = CollisionXYData(Array((0.1f, 0), (0.2f, 5), (0.50f, 15), (0.75f, 20), (1f, 30))) //not in the ADB + avatar_bot_agile.collision.z = CollisionZData(Array((0.1f, 0), (5f, 1), (10f, 3), (20f, 5), (35f, 7), (50f, 10), (75f, 40), (100f, 100))) //not in the ADB + avatar_bot_agile.maxForwardSpeed = 27f //not in the ADB; running speed + + val avatar_bot_agile_no_weapon = new AvatarBotDefinition(124) + avatar_bot_agile_no_weapon.MaxHealth = 100 + avatar_bot_agile_no_weapon.Damageable = true + avatar_bot_agile_no_weapon.DrownAtMaxDepth = true + avatar_bot_agile_no_weapon.MaxDepth = 1.609375f //Male, standing, not MAX + avatar_bot_agile_no_weapon.UnderwaterLifespan(suffocation = 60000L, recovery = 10000L) + avatar_bot_agile_no_weapon.collision.xy = CollisionXYData(Array((0.1f, 0), (0.2f, 5), (0.50f, 15), (0.75f, 20), (1f, 30))) //not in the ADB + avatar_bot_agile_no_weapon.collision.z = CollisionZData(Array((0.1f, 0), (5f, 1), (10f, 3), (20f, 5), (35f, 7), (50f, 10), (75f, 40), (100f, 100))) //not in the ADB + avatar_bot_agile_no_weapon.maxForwardSpeed = 27f //not in the ADB; running speed + + val avatar_bot_max = new AvatarBotDefinition(125) + avatar_bot_max.MaxHealth = 100 + avatar_bot_max.Damageable = true + avatar_bot_max.DrownAtMaxDepth = true + avatar_bot_max.MaxDepth = 1.609375f //Male, standing, not MAX + avatar_bot_max.UnderwaterLifespan(suffocation = 60000L, recovery = 10000L) + avatar_bot_max.collision.xy = CollisionXYData(Array((0.1f, 0), (0.2f, 5), (0.50f, 15), (0.75f, 20), (1f, 30))) //not in the ADB + avatar_bot_max.collision.z = CollisionZData(Array((0.1f, 0), (5f, 1), (10f, 3), (20f, 5), (35f, 7), (50f, 10), (75f, 40), (100f, 100))) //not in the ADB + avatar_bot_max.maxForwardSpeed = 27f //not in the ADB; running speed + + val avatar_bot_max_no_weapon = new AvatarBotDefinition(126) + avatar_bot_max_no_weapon.MaxHealth = 100 + avatar_bot_max_no_weapon.Damageable = true + avatar_bot_max_no_weapon.DrownAtMaxDepth = true + avatar_bot_max_no_weapon.MaxDepth = 1.609375f //Male, standing, not MAX + avatar_bot_max_no_weapon.UnderwaterLifespan(suffocation = 60000L, recovery = 10000L) + avatar_bot_max_no_weapon.collision.xy = CollisionXYData(Array((0.1f, 0), (0.2f, 5), (0.50f, 15), (0.75f, 20), (1f, 30))) //not in the ADB + avatar_bot_max_no_weapon.collision.z = CollisionZData(Array((0.1f, 0), (5f, 1), (10f, 3), (20f, 5), (35f, 7), (50f, 10), (75f, 40), (100f, 100))) //not in the ADB + avatar_bot_max_no_weapon.maxForwardSpeed = 27f //not in the ADB; running speed + + val avatar_bot_reinforced = new AvatarBotDefinition(127) + avatar_bot_reinforced.MaxHealth = 100 + avatar_bot_reinforced.Damageable = true + avatar_bot_reinforced.DrownAtMaxDepth = true + avatar_bot_reinforced.MaxDepth = 1.609375f //Male, standing, not MAX + avatar_bot_reinforced.UnderwaterLifespan(suffocation = 60000L, recovery = 10000L) + avatar_bot_reinforced.collision.xy = CollisionXYData(Array((0.1f, 0), (0.2f, 5), (0.50f, 15), (0.75f, 20), (1f, 30))) //not in the ADB + avatar_bot_reinforced.collision.z = CollisionZData(Array((0.1f, 0), (5f, 1), (10f, 3), (20f, 5), (35f, 7), (50f, 10), (75f, 40), (100f, 100))) //not in the ADB + avatar_bot_reinforced.maxForwardSpeed = 27f //not in the ADB; running speed + + val avatar_bot_reinforced_no_weapon = new AvatarBotDefinition(128) + avatar_bot_reinforced_no_weapon.MaxHealth = 100 + avatar_bot_reinforced_no_weapon.Damageable = true + avatar_bot_reinforced_no_weapon.DrownAtMaxDepth = true + avatar_bot_reinforced_no_weapon.MaxDepth = 1.609375f //Male, standing, not MAX + avatar_bot_reinforced_no_weapon.UnderwaterLifespan(suffocation = 60000L, recovery = 10000L) + avatar_bot_reinforced_no_weapon.collision.xy = CollisionXYData(Array((0.1f, 0), (0.2f, 5), (0.50f, 15), (0.75f, 20), (1f, 30))) //not in the ADB + avatar_bot_reinforced_no_weapon.collision.z = CollisionZData(Array((0.1f, 0), (5f, 1), (10f, 3), (20f, 5), (35f, 7), (50f, 10), (75f, 40), (100f, 100))) //not in the ADB + avatar_bot_reinforced_no_weapon.maxForwardSpeed = 27f //not in the ADB; running speed + + val avatar_bot_standard = new AvatarBotDefinition(129) + avatar_bot_standard.MaxHealth = 100 + avatar_bot_standard.Damageable = true + avatar_bot_standard.DrownAtMaxDepth = true + avatar_bot_standard.MaxDepth = 1.609375f //Male, standing, not MAX + avatar_bot_standard.UnderwaterLifespan(suffocation = 60000L, recovery = 10000L) + avatar_bot_standard.collision.xy = CollisionXYData(Array((0.1f, 0), (0.2f, 5), (0.50f, 15), (0.75f, 20), (1f, 30))) //not in the ADB + avatar_bot_standard.collision.z = CollisionZData(Array((0.1f, 0), (5f, 1), (10f, 3), (20f, 5), (35f, 7), (50f, 10), (75f, 40), (100f, 100))) //not in the ADB + avatar_bot_standard.maxForwardSpeed = 27f //not in the ADB; running speed + + val avatar_bot_standard_no_weapon = new AvatarBotDefinition(130) + avatar_bot_standard_no_weapon.MaxHealth = 100 + avatar_bot_standard_no_weapon.Damageable = true + avatar_bot_standard_no_weapon.DrownAtMaxDepth = true + avatar_bot_standard_no_weapon.MaxDepth = 1.609375f //Male, standing, not MAX + avatar_bot_standard_no_weapon.UnderwaterLifespan(suffocation = 60000L, recovery = 10000L) + avatar_bot_standard_no_weapon.collision.xy = CollisionXYData(Array((0.1f, 0), (0.2f, 5), (0.50f, 15), (0.75f, 20), (1f, 30))) //not in the ADB + avatar_bot_standard_no_weapon.collision.z = CollisionZData(Array((0.1f, 0), (5f, 1), (10f, 3), (20f, 5), (35f, 7), (50f, 10), (75f, 40), (100f, 100))) //not in the ADB + avatar_bot_standard_no_weapon.maxForwardSpeed = 27f //not in the ADB; running speed + /* exo-suits */ diff --git a/src/main/scala/net/psforever/objects/avatar/AvatarBot.scala b/src/main/scala/net/psforever/objects/avatar/AvatarBot.scala new file mode 100644 index 000000000..7ff077f5b --- /dev/null +++ b/src/main/scala/net/psforever/objects/avatar/AvatarBot.scala @@ -0,0 +1,284 @@ +// Copyright (c) 2026 PSForever +package net.psforever.objects.avatar + +import net.psforever.objects.avatar.interaction.{InteractWithForceDomeProtection, TriggerOnPlayerRule, WithEntrance, WithGantry, WithLava, WithWater} +import net.psforever.objects.{GlobalDefinitions, Player, OffhandEquipmentSlot} +import net.psforever.objects.ballistics.InteractWithRadiationClouds +import net.psforever.objects.ce.{InteractWithMines, InteractWithTurrets} +import net.psforever.objects.definition.{AvatarBotDefinition, ExoSuitDefinition} +import net.psforever.objects.equipment.{EquipmentSize, EquipmentSlot, JammableUnit} +import net.psforever.objects.inventory.{Container, GridInventory, InventoryItem} +import net.psforever.objects.serverobject.{PlanetSideServerObject, environment} +import net.psforever.objects.serverobject.affinity.FactionAffinity +import net.psforever.objects.serverobject.aura.AuraContainer +import net.psforever.objects.serverobject.environment.interaction.common.{WithDeath, WithMovementTrigger} +import net.psforever.objects.serverobject.interior.InteriorAwareFromInteraction +import net.psforever.objects.vital.Vitality +import net.psforever.objects.vital.damage.DamageProfile +import net.psforever.objects.vital.interaction.DamageInteraction +import net.psforever.objects.vital.resistance.ResistanceProfile +import net.psforever.objects.vital.resolution.DamageResistanceModel +import net.psforever.objects.zones.blockmap.BlockMapEntity +import net.psforever.objects.zones.interaction.InteractsWithZone +import net.psforever.objects.zones.ZoneAware +import net.psforever.packet.game.objectcreate.BasicCharacterData +import net.psforever.types._ + +/** + * A stripped down combination of the `Avatar` and `Player` classes. + * This acts as a base class for NPC avatars, and is used for VR Shooting Range infantry targets. + * @see `Avatar` + * @see `Player` + */ +case class AvatarBot( + basic: BasicCharacterData, + definition: AvatarBotDefinition, + bep: Long = 0, + cep: Long = 0, + certifications: Set[Certification] = Set(), + implants: Seq[Option[Implant]] = Seq(None, None, None), + decoration: ProgressDecoration = ProgressDecoration() +) extends PlanetSideServerObject + with BlockMapEntity + with InteractsWithZone + with FactionAffinity + with Vitality + with ResistanceProfile + with Container + with JammableUnit + with ZoneAware + with InteriorAwareFromInteraction + with AuraContainer { + interaction(new InteractWithForceDomeProtection()) + interaction(environment.interaction.InteractWithEnvironment(Seq( + new WithEntrance(), + new WithWater(Name), + new WithLava(), + new WithDeath(), + new WithGantry(Name), + new WithMovementTrigger() + ))) + interaction(new InteractWithMines(range = 10, TriggerOnPlayerRule)) + interaction(new InteractWithTurrets()) + interaction(new InteractWithRadiationClouds(range = 10f, None)) + + val br: BattleRank = BattleRank.withExperience(bep) + val cr: CommandRank = CommandRank.withExperience(cep) + + private var armor: Int = 0 + + private var exosuit: ExoSuitDefinition = GlobalDefinitions.Standard + private val freeHand: EquipmentSlot = new OffhandEquipmentSlot(EquipmentSize.Inventory) + private val holsters: Array[EquipmentSlot] = Array.fill[EquipmentSlot](5)(new EquipmentSlot) + private val inventory: GridInventory = GridInventory() + private var drawnSlot: Int = Player.HandsDownSlot + private var lastDrawnSlot: Int = Player.HandsDownSlot + + private var facingYawUpper: Float = 0f + private var crouching: Boolean = false + private var jumping: Boolean = false + private var cloaked: Boolean = false + + /** The maximum stamina amount */ + val maxStamina: Int = 100 + + var fatigued: Boolean = false + var stamina: Int = 100 + + //init + Health = 0 //bot health is artificially managed as a part of their lifecycle; start entity as dead + Destroyed = true //see isAlive + AvatarBot.SuitSetup(this, exosuit) + + def Definition: AvatarBotDefinition = definition + + def Name: String = basic.name + + def Faction: PlanetSideEmpire.Value = basic.faction + + def Sex: CharacterSex = basic.sex + + def Head: Int = basic.head + + def Voice: CharacterVoice.Value = basic.voice + + def isAlive: Boolean = !Destroyed + + def Spawn(): Boolean = { + if (!isAlive) { + Destroyed = false + Health = Definition.DefaultHealth + Armor = MaxArmor + } + isAlive + } + + def Die: Boolean = { + Destroyed = true + Health = 0 + false + } + + def Armor: Int = armor + + def Armor_=(assignArmor: Int): Int = { + armor = math.min(math.max(0, assignArmor), MaxArmor) + Armor + } + + def MaxArmor: Int = exosuit.MaxArmor + + override def Slot(slot: Int): EquipmentSlot = { + if (inventory.Offset <= slot && slot <= inventory.LastIndex) { + inventory.Slot(slot) + } else if (slot > -1 && slot < 5) { + holsters(slot) + } else if (slot == 5) { + OffhandEquipmentSlot.BlockedSlot + } else if (slot == Player.FreeHandSlot) { + freeHand + } else { + OffhandEquipmentSlot.BlockedSlot + } + } + + def VisibleSlots: Set[Int] = + if (exosuit.SuitType == ExoSuitType.MAX) { + Set(0) + } else { + (0 to 4).filterNot(index => holsters(index).Size == EquipmentSize.Blocked).toSet + } + + def Holsters(): Array[EquipmentSlot] = holsters + + /** + * Transform the holster equipment slots + * into a list of the kind of item wrapper found in an inventory. + * @see `GridInventory` + * @see `InventoryItem` + * @return a list of items that would be found in a proper inventory + */ + def HolsterItems(): List[InventoryItem] = holsters + .zipWithIndex + .collect { + case (slot: EquipmentSlot, index: Int) => + slot.Equipment match { + case Some(item) => Some(InventoryItem(item, index)) + case None => None + } + }.flatten.toList + + def Inventory: GridInventory = inventory + + def DrawnSlot: Int = drawnSlot + + def DrawnSlot_=(slot: Int): Int = { + if (slot != drawnSlot) { + if (slot == Player.HandsDownSlot) { + drawnSlot = slot + } else if (VisibleSlots.contains(slot) && holsters(slot).Equipment.isDefined) { + drawnSlot = slot + lastDrawnSlot = slot + } + } + DrawnSlot + } + + def LastDrawnSlot: Int = lastDrawnSlot + + def ExoSuit: ExoSuitType.Value = exosuit.SuitType + def ExoSuitDef: ExoSuitDefinition = exosuit + + def ExoSuit_=(suit: ExoSuitType.Value): Unit = { + val eSuit = ExoSuitDefinition.Select(suit, Faction) + exosuit = eSuit + AvatarBot.SuitSetup(this, eSuit) + } + + def Subtract: DamageProfile = exosuit.Subtract + + def ResistanceDirectHit: Int = exosuit.ResistanceDirectHit + + def ResistanceSplash: Int = exosuit.ResistanceSplash + + def ResistanceAggravated: Int = exosuit.ResistanceAggravated + + def RadiationShielding: Float = exosuit.RadiationShielding + + def FacingYawUpper: Float = facingYawUpper + + def FacingYawUpper_=(facing: Float): Float = { + facingYawUpper = facing + FacingYawUpper + } + + def Crouching: Boolean = crouching + + def Crouching_=(crouched: Boolean): Boolean = { + crouching = crouched + Crouching + } + + def Jumping: Boolean = jumping + + def Jumping_=(jumped: Boolean): Boolean = { + jumping = jumped + Jumping + } + + def Cloaked: Boolean = cloaked + + def Cloaked_=(isCloaked: Boolean): Boolean = { + cloaked = isCloaked + Cloaked + } + + override def CanDamage: Boolean = { + isAlive && super.CanDamage + } + + def DamageModel: DamageResistanceModel = exosuit.asInstanceOf[DamageResistanceModel] + + /** Return true if the stamina is at the maximum amount */ + def staminaFull: Boolean = { + stamina == maxStamina + } + + override def toString: String = { + val guid = if (HasGUID) { + s" $Continent-${GUID.guid}" + } else { + "" + } + s"${basic.name}$guid ${basic.faction} H: $Health/$MaxHealth A: $Armor/$MaxArmor" + } +} + +object AvatarBot { + final case class Die(reason: Option[DamageInteraction]) + + final case class Release() + + final case class Spawn() + + object Die { + def apply(): Die = Die(None) + + def apply(reason: DamageInteraction): Die = { + Die(Some(reason)) + } + } + + def apply(name: String, faction: PlanetSideEmpire.Value, sex: CharacterSex, head: Int, voice: CharacterVoice.Value, definition: AvatarBotDefinition): AvatarBot = { + AvatarBot(BasicCharacterData(name, faction, sex, head, voice), definition) + } + + private def SuitSetup(bot: AvatarBot, eSuit: ExoSuitDefinition): Unit = { + //inventory + bot.Inventory.Clear() + bot.Inventory.Resize(eSuit.InventoryScale.Width, eSuit.InventoryScale.Height) + bot.Inventory.Offset = eSuit.InventoryOffset + //holsters + (0 until 5).foreach { index => bot.Slot(index).Size = eSuit.Holster(index) } + } +} diff --git a/src/main/scala/net/psforever/objects/avatar/AvatarBotActor.scala b/src/main/scala/net/psforever/objects/avatar/AvatarBotActor.scala new file mode 100644 index 000000000..69fdd1444 --- /dev/null +++ b/src/main/scala/net/psforever/objects/avatar/AvatarBotActor.scala @@ -0,0 +1,480 @@ +// Copyright (c) 2026 PSForever +package net.psforever.objects.avatar + +import akka.actor.{Actor, ActorRef} +import net.psforever.actors.zone.ShootingRangeTargetSpawner +import net.psforever.objects.avatar.AvatarBot +import net.psforever.objects.equipment._ +import net.psforever.objects.serverobject.aura.{Aura, AuraEffectBehavior} +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.avatar.{AvatarAction, AvatarServiceMessage} +import net.psforever.services.local.{LocalAction, LocalServiceMessage} +import net.psforever.objects.serverobject.environment.interaction.RespondsToZoneEnvironment +import net.psforever.objects.sourcing.PlayerSource +import net.psforever.objects.vital.collision.CollisionReason +import net.psforever.objects.vital.etc.{PainboxReason, 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 + + 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 _ => ; + } + + 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) + //alert to damage source + cause.adversarial match { + case Some(adversarial) => + adversarial.attacker match { + case pSource: PlayerSource => //bot damage + val name = pSource.Name + zone.LivePlayers.find(_.Name == name).orElse(zone.Corpses.find(_.Name == name)) match { + case Some(tplayer) => + zone.AvatarEvents ! AvatarServiceMessage( + target.Name, + AvatarAction.HitHint(tplayer.GUID, target.GUID) + ) + case None => + zone.AvatarEvents ! AvatarServiceMessage( + target.Name, + AvatarAction.SendResponse( + targetGUID, + DamageWithPositionMessage(countableDamage, pSource.Position) + ) + ) + } + case source => + zone.AvatarEvents ! AvatarServiceMessage( + target.Name, + AvatarAction.SendResponse( + targetGUID, + DamageWithPositionMessage(countableDamage, source.Position) + ) + ) + } + case None => + cause.interaction.cause match { + case o: PainboxReason => + zone.AvatarEvents ! AvatarServiceMessage( + target.Name, + AvatarAction.EnvironmentalDamage(target.GUID, o.entity.GUID, countableDamage) + ) + case _: CollisionReason => + events ! AvatarServiceMessage( + zoneId, + AvatarAction.SendResponse(targetGUID, AggravatedDamageMessage(targetGUID, countableDamage)) + ) + case _ => + zone.AvatarEvents ! AvatarServiceMessage( + target.Name, + AvatarAction.EnvironmentalDamage(target.GUID, ValidPlanetSideGUID(0), countableDamage) + ) + } + } + } + } + } + + /** + * The bot has lost all his vitality and must be killed.
+ *
+ * Shift directly into a state of being dead on the client by setting health to zero points, + * whereupon the bot will perform a dramatic death animation. + * Stamina is also set to zero points. + * Do not move or completely destroy the `AvatarBot` object as its coordinates of death will be important.
+ *
+ * @param target na + * @param cause na + */ + def DestructionAwareness(target: AvatarBot, cause: DamageResult): Unit = { + val bot_guid = target.GUID + val pos = target.Position + val zone = target.Zone + val events = zone.AvatarEvents + val nameChannel = target.Name + val zoneChannel = zone.id + target.Die + //aura effects cancel + EndAllEffects() + //aggravation cancel + EndAllAggravation() + //unjam + CancelJammeredSound(target) + super.CancelJammeredStatus(target) + //no stamina + target.stamina = 0 + + //log historical event + target.LogActivity(cause) + //log message + cause.adversarial match { + case Some(a) => + damageLog.info(s"${a.defender.Name} was killed by ${a.attacker.Name}") + events ! AvatarServiceMessage( + zoneChannel, + AvatarAction.DestroyDisplay(a.attacker, a.defender, a.implement) + ) + case _ => + damageLog.info(s"${bot.Name} killed ${bot.Sex.pronounObject}self") + events ! AvatarServiceMessage(zoneChannel, AvatarAction.DestroyDisplay(cause.interaction.target, cause.interaction.target, 0)) + } + + events ! AvatarServiceMessage(nameChannel, AvatarAction.Killed(bot_guid, cause, None)) //align client interface fields with state + events ! AvatarServiceMessage(zoneChannel, AvatarAction.PlanetsideAttributeToAll(bot_guid, 0, 0)) //health + val attribute = DamageableEntity.attributionTo(cause, target.Zone, bot_guid) + events ! AvatarServiceMessage( + nameChannel, + AvatarAction.SendResponse( + bot_guid, + DestroyMessage(bot_guid, attribute, bot_guid, pos) + ) //how many players get this message? + ) + + context.self ! AvatarBot.Release() + } + + def suicide() : Unit = { + if (bot.Health > 0 || bot.isAlive) { + PerformDamage( + bot, + DamageInteraction( + PlayerSource(bot), + SuicideReason(), + bot.Position + ).calculate() + ) + } + } + + private def spawn(): Unit = { + bot.Spawn() + bot.Zone.Population ! Zone.Bots.Spawn(bot) + canEmote = true + 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() + 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 UpdateAuraEffect(target: AuraEffectBehavior.Target) : Unit = { + import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage} + val zone = target.Zone + val value = target.Aura.foldLeft(0)(_ + AvatarBotActor.auraEffectToAttributeValue(_)) + zone.AvatarEvents ! AvatarServiceMessage(zone.id, AvatarAction.PlanetsideAttributeToAll(target.GUID, 54, value)) + } +} + +object AvatarBotActor { + /** + * Transform an applicable Aura effect into its `PlanetsideAttributeMessage` value. + * @see `Aura` + * @see `PlanetsideAttributeMessage` + * @param effect the aura effect + * @return the attribute value for that effect + */ + private def auraEffectToAttributeValue(effect: Aura): Int = effect match { + case Aura.Plasma => 1 + case Aura.Comet => 2 + case Aura.Napalm => 4 + case Aura.Fire => 8 + case _ => 0 + } +} diff --git a/src/main/scala/net/psforever/objects/avatar/PlayerControl.scala b/src/main/scala/net/psforever/objects/avatar/PlayerControl.scala index 955b9886e..d597ea565 100644 --- a/src/main/scala/net/psforever/objects/avatar/PlayerControl.scala +++ b/src/main/scala/net/psforever/objects/avatar/PlayerControl.scala @@ -417,11 +417,15 @@ class PlayerControl(player: Player, avatarActor: typed.ActorRef[AvatarActor.Comm } val hasCavernEquipmentBenefit: Boolean = terminalOpt.exists { terminal => - terminal.Owner match { - case fac: Building => - fac.hasCavernLockBenefit && player.Faction == fac.Faction - case _ => - false + if (terminal.IsInVRZone) { + true + } else { + terminal.Owner match { + case fac: Building => + fac.hasCavernLockBenefit && player.Faction == fac.Faction + case _ => + false + } } } val dropPred = diff --git a/src/main/scala/net/psforever/objects/ballistics/InteractWithRadiationClouds.scala b/src/main/scala/net/psforever/objects/ballistics/InteractWithRadiationClouds.scala index 19e18316d..6a9365de7 100644 --- a/src/main/scala/net/psforever/objects/ballistics/InteractWithRadiationClouds.scala +++ b/src/main/scala/net/psforever/objects/ballistics/InteractWithRadiationClouds.scala @@ -27,17 +27,28 @@ class InteractWithRadiationClouds( val position = target.Position projectiles .foreach { projectile => - target.Actor ! Vitality.Damage( - DamageInteraction( - SourceEntry(target), - RadiationReason( - ProjectileQuality.modifiers(projectile, DamageResolution.Radiation, target, target.Position, user), - target.DamageModel, - RadiationCloudInteraction.RadiationShieldingFrom(target) - ), - position - ).calculate() - ) + val shouldDamage = user match { + case Some(player) if (player.IsInVRZone && target.Faction == player.Faction) => + //disable self-damage and friendly-fire in VR zones + false + case Some(player) => + true + case None => + true + } + if (shouldDamage) { + target.Actor ! Vitality.Damage( + DamageInteraction( + SourceEntry(target), + RadiationReason( + ProjectileQuality.modifiers(projectile, DamageResolution.Radiation, target, target.Position, user), + target.DamageModel, + RadiationCloudInteraction.RadiationShieldingFrom(target) + ), + position + ).calculate() + ) + } } } } diff --git a/src/main/scala/net/psforever/objects/definition/AvatarBotDefinition.scala b/src/main/scala/net/psforever/objects/definition/AvatarBotDefinition.scala new file mode 100644 index 000000000..128671a62 --- /dev/null +++ b/src/main/scala/net/psforever/objects/definition/AvatarBotDefinition.scala @@ -0,0 +1,33 @@ +// Copyright (c) 2026 PSForever +package net.psforever.objects.definition + +import net.psforever.objects.avatar.Avatars +import net.psforever.objects.definition.converter.AvatarBotConverter +import net.psforever.objects.geometry.GeometryForm +import net.psforever.objects.vital.VitalityDefinition + +/** + * The definition for game objects that look like players. + * @param objectId the object type number + */ +class AvatarBotDefinition(objectId: Int) extends ObjectDefinition(objectId) with VitalityDefinition { + Name = "avatar_bot" + Avatars(objectId) //let throw NoSuchElementException + Packet = AvatarBotDefinition.converter + Geometry = GeometryForm.representPlayerByCylinder(radius = 1.6f) + //do NOT attempt to create AvatarBot's outside of the VR Shooting Range zones with this definition, + //space will need to be made to add a "bots" pool to the target zone + registerAs = "bots" +} + +object AvatarBotDefinition { + private val converter = new AvatarBotConverter() + + def apply(objectId: Int): AvatarBotDefinition = { + new AvatarBotDefinition(objectId) + } + + def apply(avatar: Avatars.Value): AvatarBotDefinition = { + new AvatarBotDefinition(avatar.id) + } +} diff --git a/src/main/scala/net/psforever/objects/definition/converter/AvatarBotConverter.scala b/src/main/scala/net/psforever/objects/definition/converter/AvatarBotConverter.scala new file mode 100644 index 000000000..44ad86bd3 --- /dev/null +++ b/src/main/scala/net/psforever/objects/definition/converter/AvatarBotConverter.scala @@ -0,0 +1,306 @@ +// Copyright (c) 2026 PSForever +package net.psforever.objects.definition.converter + +import net.psforever.objects.Player +import net.psforever.objects.avatar.{AvatarBot, BattleRank} +import net.psforever.objects.equipment.{Equipment, EquipmentSlot} +import net.psforever.packet.game.objectcreate._ +import net.psforever.types.{ExoSuitType, GrenadeState, PlanetSideEmpire, PlanetSideGUID} + +import scala.annotation.tailrec +import scala.util.{Success, Try} + +class AvatarBotConverter extends ObjectCreateConverter[AvatarBot]() { + override def ConstructorData(obj: AvatarBot): Try[PlayerData] = { + import AvatarBotConverter._ + Success( + PlayerData( + PlacementData(obj.Position, obj.Orientation, None), + MakeAppearanceData(obj), + MakeCharacterData(obj), + MakeInventoryData(obj), + GetDrawnSlot(obj) + ) + ) + } + + override def DetailedConstructorData(obj: AvatarBot): Try[DetailedPlayerData] = { + import AvatarBotConverter._ + Success( + DetailedPlayerData.apply( + PlacementData(obj.Position, obj.Orientation, None), + MakeAppearanceData(obj), + MakeDetailedCharacterData(obj), + MakeDetailedInventoryData(obj), + GetDrawnSlot(obj) + ) + ) + } +} + +object AvatarBotConverter { + + /** + * Compose some data from a `AvatarBot` into a representation common to both `CharacterData` and `DetailedCharacterData`. + * @param obj the `AvatarBot` game object + * @return the resulting `CharacterAppearanceData` + */ + def MakeAppearanceData(obj: AvatarBot): Int => CharacterAppearanceData = { + val aa: Int => CharacterAppearanceA = CharacterAppearanceA( + obj.basic, + CommonFieldData( + obj.Faction, + bops = false, + false, + v1 = false, + None, + obj.Jammed, + None, + v5 = None, + PlanetSideGUID(0) + ), + obj.ExoSuit, + 0, + 0, + 0, + 0, + 0, + 0 + ) + val ab: (Boolean, Int) => CharacterAppearanceB = CharacterAppearanceB( + 0, + "", + outfit_logo = 0, + unk1 = false, + false, + unk2 = false, + unk3 = false, + unk4 = false, + facingPitch = obj.Orientation.y, + facingYawUpper = obj.FacingYawUpper, + false, + GrenadeState.None, + obj.Cloaked, + unk5 = false, + unk6 = false, + charging_pose = false, + unk7 = false, + on_zipline = None + ) + CharacterAppearanceData(aa, ab, obj.decoration.ribbonBars) + } + + def MakeCharacterData(obj: AvatarBot): (Boolean, Boolean) => CharacterData = { + val uniformStyle = obj.br.uniformStyle + val cosmetics = if (BattleRank.showCosmetics(uniformStyle)) { + obj.decoration.cosmetics + } else { + None + } + val MaxArmor = obj.MaxArmor + val armor = if (MaxArmor == 0) { + 0 + } else { + StatConverter.Health(obj.Armor, MaxArmor) + } + CharacterData( + StatConverter.Health(obj.Health, obj.MaxHealth), + armor, + uniformStyle, + 0, + obj.cr.value, + obj.implants.flatten.filter(_.active).flatMap(_.definition.implantType.effect).toList, + cosmetics + ) + } + + def MakeDetailedCharacterData(obj: AvatarBot): Option[Int] => DetailedCharacterData = { + val maxOpt: Option[Long] = if (obj.ExoSuit == ExoSuitType.MAX) { + Some(0L) + } else { + None + } + val cosmetics = if (BattleRank.BR24.experience >= obj.bep) { + obj.decoration.cosmetics + } else { + None + } + val ba: DetailedCharacterA = DetailedCharacterA( + obj.bep, + obj.cep, + 0L, + 0L, + 0L, + obj.MaxHealth, + obj.Health, + unk4 = false, + obj.Armor, + 0L, + obj.maxStamina, + obj.stamina, + maxOpt, + 0, + 0, + 0L, + List(0, 0, 0, 0, 0, 0), + obj.certifications.toList.sortBy(_.value) //TODO is sorting necessary? + ) + val bb: (Long, Option[Int]) => DetailedCharacterB = DetailedCharacterB( + None, + Nil, + Nil, + Nil, + Nil, + tutorials = List.empty[String], //TODO tutorial list + 0L, + 0L, + 0L, + 0L, + 0L, + None, //Some(ImprintingProgress(0, 0)), + Nil, + Nil, + unkC = false, + cosmetics + ) + pad_length: Option[Int] => DetailedCharacterData(ba, bb(0, pad_length))(pad_length) + } + + def MakeInventoryData(obj: AvatarBot): InventoryData = { + InventoryData(MakeHolsters(obj, BuildEquipment)) + } + + def MakeDetailedInventoryData(obj: AvatarBot): InventoryData = { + InventoryData( + MakeHolsters(obj, BuildDetailedEquipment) ++ + MakeFifthSlot(obj) ++ + MakeInventory(obj) + ) + } + + /** + * Given a player with an inventory, convert the contents of that inventory into converted-decoded packet data. + * The inventory is not represented in a `0x17` `AvatarBot`, so the conversion is only valid for `0x18` avatars. + * It will always be "`Detailed`". + * @param obj the `AvatarBot` game object + * @return a list of all items that were in the inventory in decoded packet form + */ + private def MakeInventory(obj: AvatarBot): List[InternalSlot] = { + obj.Inventory.Items + .map(item => { + val equip: Equipment = item.obj + InternalSlot( + equip.Definition.ObjectId, + equip.GUID, + item.start, + equip.Definition.Packet.DetailedConstructorData(equip).get + ) + }) + } + + /** + * Given a player with equipment holsters, convert the contents of those holsters into converted-decoded packet data. + * The decoded packet form is determined by the function in the parameters as both `0x17` and `0x18` conversions are available, + * with exception to the contents of the fifth slot. + * The fifth slot is only represented if the `AvatarBot` is an `0x18` type. + * @param obj the `AvatarBot` game object + * @param builder the function used to transform to the decoded packet form + * @return a list of all items that were in the holsters in decoded packet form + */ + def MakeHolsters(obj: AvatarBot, builder: (Int, Equipment) => InternalSlot): List[InternalSlot] = { + recursiveMakeHolsters(obj.Holsters().iterator, builder) + } + + /** + * Given a player with equipment holsters, convert any content of the fifth holster slot into converted-decoded packet data. + * The fifth holster is a curious divider between the standard holsters and the formal inventory. + * This fifth slot is only ever represented if the `AvatarBot` is an `0x18` type. + * @param obj the `AvatarBot` game object + * @return a list of any item that was in the fifth holster in decoded packet form + */ + private def MakeFifthSlot(obj: AvatarBot): List[InternalSlot] = { + obj.Slot(slot = 5).Equipment match { + case Some(equip) => + List(InternalSlot( + equip.Definition.ObjectId, + equip.GUID, + 5, + DetailedLockerContainerData( + CommonFieldData(PlanetSideEmpire.NEUTRAL, bops=false, alternate=false, v1=true, None, jammered=false, None, None, PlanetSideGUID(0)), + None + ) + )) + case _ => + Nil + } + } + + /** + * A builder method for turning an object into `0x17` decoded packet form. + * @param index the position of the object + * @param equip the game object + * @return the game object in decoded packet form + */ + private def BuildEquipment(index: Int, equip: Equipment): InternalSlot = { + InternalSlot(equip.Definition.ObjectId, equip.GUID, index, equip.Definition.Packet.ConstructorData(equip).get) + } + + /** + * A builder method for turning an object into `0x18` decoded packet form. + * @param index the position of the object + * @param equip the game object + * @return the game object in decoded packet form + */ + def BuildDetailedEquipment(index: Int, equip: Equipment): InternalSlot = { + InternalSlot( + equip.Definition.ObjectId, + equip.GUID, + index, + equip.Definition.Packet.DetailedConstructorData(equip).get + ) + } + + /** + * Given some equipment holsters, convert the contents of those holsters into converted-decoded packet data. + * @param iter an `Iterator` of `EquipmentSlot` objects that are a part of the player's holsters + * @param builder the function used to transform to the decoded packet form + * @param list the current `List` of transformed data + * @param index which holster is currently being explored + * @return the `List` of inventory data created from the holsters + */ + @tailrec private def recursiveMakeHolsters( + iter: Iterator[EquipmentSlot], + builder: (Int, Equipment) => InternalSlot, + list: List[InternalSlot] = Nil, + index: Int = 0 + ): List[InternalSlot] = { + if (!iter.hasNext) { + list + } else { + val slot: EquipmentSlot = iter.next() + if (slot.Equipment.isDefined) { + val equip: Equipment = slot.Equipment.get + recursiveMakeHolsters( + iter, + builder, + list :+ builder(index, equip), + index + 1 + ) + } else { + recursiveMakeHolsters(iter, builder, list, index + 1) + } + } + } + + /** + * Resolve which holster the player has drawn, if any. + * @param obj the `AvatarBot` game object + * @return the holster's Enumeration value + */ + def GetDrawnSlot(obj: AvatarBot): DrawnSlot.Value = { + obj.DrawnSlot match { + case Player.HandsDownSlot | Player.FreeHandSlot => DrawnSlot.None + case n => DrawnSlot(n) + } + } +} diff --git a/src/main/scala/net/psforever/objects/equipment/EffectTarget.scala b/src/main/scala/net/psforever/objects/equipment/EffectTarget.scala index 867db90a7..e0eea8f8b 100644 --- a/src/main/scala/net/psforever/objects/equipment/EffectTarget.scala +++ b/src/main/scala/net/psforever/objects/equipment/EffectTarget.scala @@ -2,6 +2,7 @@ package net.psforever.objects.equipment import net.psforever.objects._ +import net.psforever.objects.avatar.AvatarBot import net.psforever.objects.ce.{DeployableCategory, DeployedItem} import net.psforever.objects.serverobject.turret.{FacilityTurret, WeaponTurret} import net.psforever.objects.vital.{DamagingActivity, InGameHistory, Vitality} @@ -111,6 +112,8 @@ object EffectTarget { target match { case p: Player => p.isAlive + case b: AvatarBot => + b.isAlive case _ => false } diff --git a/src/main/scala/net/psforever/objects/geometry/GeometryForm.scala b/src/main/scala/net/psforever/objects/geometry/GeometryForm.scala index 070ae972e..fcd1daee1 100644 --- a/src/main/scala/net/psforever/objects/geometry/GeometryForm.scala +++ b/src/main/scala/net/psforever/objects/geometry/GeometryForm.scala @@ -1,6 +1,7 @@ // Copyright (c) 2021 PSForever package net.psforever.objects.geometry +import net.psforever.objects.avatar.AvatarBot import net.psforever.objects.ballistics.Projectile import net.psforever.objects.geometry.d3._ import net.psforever.objects.sourcing.{PlayerSource, SourceEntry} @@ -152,6 +153,13 @@ object GeometryForm { radius + radialOffset, heightOffset ) + case b: AvatarBot => + val radialOffset = if(b.ExoSuit == ExoSuitType.MAX) 0.25f else 0f + Cylinder( + b.Position, + radius + radialOffset, + GlobalDefinitions.MaxDepth(b) + ) case _ => invalidCylinder } diff --git a/src/main/scala/net/psforever/objects/guid/GUIDTask.scala b/src/main/scala/net/psforever/objects/guid/GUIDTask.scala index cd86cfafc..51eb7ef3f 100644 --- a/src/main/scala/net/psforever/objects/guid/GUIDTask.scala +++ b/src/main/scala/net/psforever/objects/guid/GUIDTask.scala @@ -2,6 +2,7 @@ package net.psforever.objects.guid import akka.util.Timeout +import net.psforever.objects.avatar.AvatarBot import net.psforever.objects.entity.IdentifiableEntity import net.psforever.objects.equipment.{Equipment, EquipmentSlot} import net.psforever.objects._ @@ -203,6 +204,19 @@ object GUIDTask { TaskBundle(RegisterObjectTask(guid, tplayer), holsterTasks ++ inventoryTasks) } + /** + * Construct tasking that registers an object with a globally unique identifier selected from a pool of numbers, as an `AvatarBot`.
+ *
+ * @param bot the `AvatarBot` object being registered + * @param guid implicit reference to a unique number system + * @return a `TaskBundle` message + */ + def registerBot(guid: UniqueNumberOps, bot: AvatarBot): TaskBundle = { + val holsterTasks = visibleSlotTaskBuilding(guid, bot.Holsters(), registerEquipment) + val inventoryTasks = registerInventory(guid, bot) + TaskBundle(RegisterObjectTask(guid, bot), holsterTasks ++ inventoryTasks) + } + /** * Construct tasking that registers an object with a globally unique identifier selected from a pool of numbers, as a `Vehicle`.
*
@@ -370,6 +384,21 @@ object GUIDTask { TaskBundle(UnregisterObjectTask(guid, tplayer), holsterTasks ++ inventoryTasks) } + /** + * Construct tasking that unregisters a `AvatarBot` object from a globally unique identifier system.
+ *
+ * This task performs an operation that reverses the effect of `RegisterBot`. + * @param bot the `AvatarBot` object being unregistered + * @param guid implicit reference to a unique number system + * @see `GUIDTask.registerAvatar` + * @return a `TaskBundle` message + */ + def unregisterBot(guid: UniqueNumberOps, bot: AvatarBot): TaskBundle = { + val holsterTasks = visibleSlotTaskBuilding(guid, bot.Holsters(), unregisterEquipment) + val inventoryTasks = unregisterInventory(guid, bot) + TaskBundle(UnregisterObjectTask(guid, bot), holsterTasks ++ inventoryTasks) + } + /** * Construct tasking that unregisters a `Vehicle` object from a globally unique identifier system.
*
diff --git a/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/ExclusionRule.scala b/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/ExclusionRule.scala index 6ff87ed0b..fe2cff23d 100644 --- a/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/ExclusionRule.scala +++ b/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/ExclusionRule.scala @@ -67,13 +67,14 @@ case object NoCavernEquipmentRule extends ExclusionRule { /** * Do not allow the player to spawn cavern equipment if not pulled from a facility and * only if the facility is subject to the benefit of an appropriate cavern perk. + * This is bypassed for VR Training terminals. */ case object CavernEquipmentQuestion extends ExclusionRule { def checkRule(player: Player, msg: ItemTransactionMessage, obj: Any): Boolean = { obj match { case equipment: Equipment => import net.psforever.objects.serverobject.structures.Building - if(GlobalDefinitions.isCavernWeapon(equipment.Definition)) { + if(GlobalDefinitions.isCavernWeapon(equipment.Definition) && !player.IsInVRZone) { (player.Zone.GUID(msg.terminal_guid) match { case Some(term: Amenity) => Some(term.Owner) case _ => None @@ -106,6 +107,7 @@ final case class NoVehicleRule(illegalDefinition: VehicleDefinition) extends Exc /** * Do not allow the player to spawn cavern vehicles if not pulled from a facility and * only if the facility is subject to the benefit of an appropriate cavern perk. + * This is bypassed for VR Training terminals. */ case object CavernVehicleQuestion extends ExclusionRule { def checkRule(player: Player, msg: ItemTransactionMessage, obj: Any): Boolean = { @@ -113,7 +115,7 @@ case object CavernVehicleQuestion extends ExclusionRule { case vehicle: Vehicle => import net.psforever.objects.serverobject.structures.Building val definition = vehicle.Definition - if (definition == GlobalDefinitions.flail || definition == GlobalDefinitions.switchblade) { + if ((definition == GlobalDefinitions.flail || definition == GlobalDefinitions.switchblade) && !player.IsInVRZone) { (player.Zone.GUID(msg.terminal_guid) match { case Some(term: Amenity) => Some(term.Owner) case _ => None diff --git a/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/VehiclePage.scala b/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/VehiclePage.scala index 8c553b051..3f9716d7c 100644 --- a/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/VehiclePage.scala +++ b/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/VehiclePage.scala @@ -24,7 +24,7 @@ final case class VehiclePage(stock: Map[String, () => Vehicle], trunk: Map[Strin stock.get(msg.item_name) match { case Some(vehicle) => val createdVehicle = vehicle() - if(!Exclude.exists(_.checkRule(player, msg, createdVehicle)) || player.IsInVRZone) { + if(!Exclude.exists(_.checkRule(player, msg, createdVehicle))) { val (weapons, inventory) = trunk.get(msg.item_name) match { case Some(loadout: VehicleLoadout) => ( diff --git a/src/main/scala/net/psforever/objects/sourcing/PlayerSource.scala b/src/main/scala/net/psforever/objects/sourcing/PlayerSource.scala index 042ed7db3..b919687d1 100644 --- a/src/main/scala/net/psforever/objects/sourcing/PlayerSource.scala +++ b/src/main/scala/net/psforever/objects/sourcing/PlayerSource.scala @@ -1,6 +1,7 @@ // Copyright (c) 2017 PSForever package net.psforever.objects.sourcing +import net.psforever.objects.avatar.AvatarBot import net.psforever.objects.avatar.scoring.Life import net.psforever.objects.definition.{AvatarDefinition, ExoSuitDefinition} import net.psforever.objects.serverobject.affinity.FactionAffinity @@ -72,6 +73,27 @@ object PlayerSource { ) } + def apply(b: AvatarBot): PlayerSource = { + val exosuit = b.ExoSuit + val faction = b.Faction + PlayerSource( + GlobalDefinitions.avatar, + exosuit, + seatedIn = None, + b.Health, + b.Armor, + b.Position, + b.Orientation, + b.Velocity, + b.Crouching, + b.Jumping, + ExoSuitDefinition.Select(exosuit, faction), + b.bep, + progress = tokenLife, + UniquePlayer(0L, b.Name, b.Sex, faction) + ) + } + def apply(name: String, faction: PlanetSideEmpire.Value, position: Vector3): PlayerSource = { this(UniquePlayer(0L, name, CharacterSex.Male, faction), position) } diff --git a/src/main/scala/net/psforever/objects/sourcing/SourceEntry.scala b/src/main/scala/net/psforever/objects/sourcing/SourceEntry.scala index 81b20bf40..d2c209707 100644 --- a/src/main/scala/net/psforever/objects/sourcing/SourceEntry.scala +++ b/src/main/scala/net/psforever/objects/sourcing/SourceEntry.scala @@ -1,6 +1,7 @@ // Copyright (c) 2017 PSForever package net.psforever.objects.sourcing +import net.psforever.objects.avatar.AvatarBot import net.psforever.objects.ce.Deployable import net.psforever.objects.definition.ObjectDefinition import net.psforever.objects.serverobject.affinity.FactionAffinity @@ -49,6 +50,7 @@ object SourceEntry { def apply(target: PlanetSideGameObject with FactionAffinity): SourceEntry = { target match { case obj: Player => PlayerSource(obj) + case obj: AvatarBot => PlayerSource(obj) case obj: Vehicle => VehicleSource(obj) case obj: FacilityTurret => TurretSource(obj) case obj: Amenity => AmenitySource(obj) diff --git a/src/main/scala/net/psforever/objects/vehicles/control/VehicleControl.scala b/src/main/scala/net/psforever/objects/vehicles/control/VehicleControl.scala index 78c423197..3c196b71c 100644 --- a/src/main/scala/net/psforever/objects/vehicles/control/VehicleControl.scala +++ b/src/main/scala/net/psforever/objects/vehicles/control/VehicleControl.scala @@ -2,7 +2,7 @@ package net.psforever.objects.vehicles.control import akka.actor.Cancellable -import net.psforever.actors.zone.ZoneActor +import net.psforever.actors.zone.{ShootingRangeTargetSpawner, ZoneActor} import net.psforever.objects._ import net.psforever.objects.avatar.SpecialCarry import net.psforever.objects.definition.{VehicleDefinition, WithShields} @@ -266,6 +266,15 @@ class VehicleControl(vehicle: Vehicle) PrepareForDisabled(kickPassengers) context.become(Disabled) + case Vehicle.Deconstruct(_) if (vehicle.Zone.id.startsWith("tzsh") && vehicle.OwnerGuid.isEmpty) => + //deconstruct the vehicle immediately if this is a VR Shooting Range target + context.become(ReadyToDelete) + //cancel jammed behavior + CancelJammeredSound(vehicle) + CancelJammeredStatus(vehicle) + self ! VehicleControl.Deletion() + vehicle.ClearHistory() + case Vehicle.Deconstruct(time) => time match { case Some(delay) if vehicle.Definition.undergoesDecay => @@ -326,6 +335,13 @@ class VehicleControl(vehicle: Vehicle) VehicleAction.UnloadVehicle(Service.defaultPlayerGUID, vehicle, vehicle.GUID) ) zone.Transport.tell(Zone.Vehicle.Despawn(vehicle), zone.Transport) + //notify target spawner that the vehicle has despawned if this is a VR Shooting Range zone + //todo: make this behavior cleaner + if (zone.id.startsWith("tzsh") && vehicle.OwnerGuid.isEmpty && zone.NPCPopulation != Default.Actor) { + zone.NPCPopulation.tell(ShootingRangeTargetSpawner.VehicleTargetDeconstructed(vehicle, vehicle.Position), zone.NPCPopulation) + } + //unregister + TaskWorkflow.execute(GUIDTask.unregisterVehicle(zone.GUID, vehicle)) } final def ReadyToDelete: Receive = commonDeleteBehavior @@ -448,8 +464,16 @@ class VehicleControl(vehicle: Vehicle) //cancel jammed behavior CancelJammeredSound(vehicle) CancelJammeredStatus(vehicle) - //unregister - TaskWorkflow.execute(GUIDTask.unregisterVehicle(zone.GUID, vehicle)) + if (!vehicle.OwnerGuid.isEmpty) { + //remove vehicle ownership is not already removed + val obj = MountableObject + Vehicles.Disown(obj.GUID, obj) + } + //lock the vehicle to prevent interaction attempts during deletion + vehicle.PermissionGroup(0, 0) + vehicle.PermissionGroup(1, 0) + vehicle.PermissionGroup(2, 0) + vehicle.PermissionGroup(3, 0) //banished to the shadow realm vehicle.Position = Vector3.Zero vehicle.ClearHistory() diff --git a/src/main/scala/net/psforever/objects/vital/resolution/ResolutionCalculations.scala b/src/main/scala/net/psforever/objects/vital/resolution/ResolutionCalculations.scala index 44774f2c8..b84630ddf 100644 --- a/src/main/scala/net/psforever/objects/vital/resolution/ResolutionCalculations.scala +++ b/src/main/scala/net/psforever/objects/vital/resolution/ResolutionCalculations.scala @@ -2,6 +2,7 @@ package net.psforever.objects.vital.resolution import net.psforever.objects._ +import net.psforever.objects.avatar.AvatarBot import net.psforever.objects.ce.Deployable import net.psforever.objects.serverobject.affinity.FactionAffinity import net.psforever.objects.serverobject.damage.Damageable @@ -236,6 +237,50 @@ object ResolutionCalculations { player.avatar.copy(stamina = math.max(0, player.avatar.stamina - math.floor(delta / 2).toInt)) } } + case bot: AvatarBot if noDoubleLash(bot, data) => + var (a, b) = damageValues + if (bot.isAlive && !(a == 0 && b == 0)) { + val originalHealth = bot.Health + if (data.cause.source.DamageToHealthOnly) { + bot.Health = SubtractWithRemainder(bot.Health, a)._1 + } else { + var result = (0, 0) + bot.implants.flatten.find(x => x.definition.implantType == ImplantType.PersonalShield) match { + case Some(implant) if implant.active => + // Subtract armour damage from stamina + result = SubtractWithRemainder(bot.stamina, b) + bot.stamina = result._1 + b = result._2 + + // Then follow up with health damage if any stamina is left + result = SubtractWithRemainder(bot.stamina, a) + bot.stamina = result._1 + a = result._2 + + case _ => ; + } + + // Subtract any remaining armour damage from armour + result = SubtractWithRemainder(bot.Armor, b) + bot.Armor = result._1 + b = result._2 + // Then bleed through to health if armour ran out + result = SubtractWithRemainder(bot.Health, b) + bot.Health = result._1 + b = result._2 + + // Finally, apply health damage to health + result = SubtractWithRemainder(bot.Health, a) + bot.Health = result._1 + //if b > 0 (armor) or result._2 > 0 (health), then we did the math wrong + } + + // If any health damage was applied also drain an amount of stamina equal to half the health damage + if (bot.Health < originalHealth) { + val delta = originalHealth - bot.Health + bot.stamina = math.max(0, bot.stamina - math.floor(delta / 2).toInt) + } + } case _ => } DamageResult(targetBefore, SourceEntry(target), data) @@ -336,7 +381,7 @@ object ResolutionCalculations { def WildcardApplication(damage: Any, data: DamageInteraction)(target: PlanetSideGameObject with FactionAffinity): DamageResult = { target match { - case _: Player => + case _: Player | _: AvatarBot => val dam : (Int, Int) = damage match { case (a: Int, b: Int) => (a, b) case a: Int => (a, 0) diff --git a/src/main/scala/net/psforever/objects/zones/Zone.scala b/src/main/scala/net/psforever/objects/zones/Zone.scala index 60cc751dd..61ee9a1fc 100644 --- a/src/main/scala/net/psforever/objects/zones/Zone.scala +++ b/src/main/scala/net/psforever/objects/zones/Zone.scala @@ -32,9 +32,9 @@ import scalax.collection.GraphEdge._ import scala.util.Try import akka.actor.typed import net.psforever.actors.session.AvatarActor -import net.psforever.actors.zone.{BuildingActor, ZoneActor} +import net.psforever.actors.zone.{BuildingActor, ShootingRangeTargetSpawnerActor, ZoneActor} import net.psforever.actors.zone.building.WarpGateLogic -import net.psforever.objects.avatar.{Avatar, PlayerControl} +import net.psforever.objects.avatar.{Avatar, AvatarBot, PlayerControl} import net.psforever.objects.definition.ObjectDefinition import net.psforever.objects.geometry.d3.VolumetricGeometry import net.psforever.objects.guid.pool.NumberPool @@ -134,6 +134,10 @@ class Zone(val id: String, val map: ZoneMap, zoneNumber: Int) { */ private val players: TrieMap[Int, Option[Player]] = TrieMap[Int, Option[Player]]() + /** + */ + private val bots: ListBuffer[AvatarBot] = ListBuffer[AvatarBot]() + /** */ private val corpses: ListBuffer[Player] = ListBuffer[Player]() @@ -145,6 +149,11 @@ class Zone(val id: String, val map: ZoneMap, zoneNumber: Int) { */ private var population: ActorRef = Default.Actor + /** + * Actor that handles non-player controlled entities, used for VR Shooting Range targets. + */ + private var npcPopulation: ActorRef = Default.Actor + private var buildings: PairMap[Int, Building] = PairMap.empty[Int, Building] private var lattice: Graph[Building, UnDiEdge] = Graph() @@ -444,6 +453,8 @@ class Zone(val id: String, val map: ZoneMap, zoneNumber: Int) { def Players: List[Avatar] = AllPlayers.map(_.avatar) + def BotAvatars: List[AvatarBot] = bots.toList + def LivePlayers: List[Player] = AllPlayers.filterNot(_.spectator) def Spectator: List[Player] = AllPlayers.filter(_.spectator) @@ -470,6 +481,8 @@ class Zone(val id: String, val map: ZoneMap, zoneNumber: Int) { def Population: ActorRef = population + def NPCPopulation: ActorRef = npcPopulation + def Buildings: Map[Int, Building] = buildings def Building(id: Int): Option[Building] = { @@ -1221,6 +1234,21 @@ object Zone { final case class PlayerCanNotSpawn(zone: Zone, player: Player) } + object Bots { + + /** + * Message that reports to the zone of a freshly spawned bot. + * @param bot the `AvatarBot` + */ + final case class Spawn(bot: AvatarBot) + + /** + * Message that tells the zone to no longer mind the bot. + * @param bot the `AvatarBot` + */ + final case class Release(bot: AvatarBot) + } + object Corpse { /** @@ -1574,13 +1602,18 @@ object Zone { zone.deployables = context.actorOf(Props(classOf[ZoneDeployableActor], zone, zone.constructions, zone.linkDynamicTurretWeapon), s"$id-deployables") zone.projectiles = context.actorOf(Props(classOf[ZoneProjectileActor], zone, zone.projectileList), s"$id-projectiles") zone.transport = context.actorOf(Props(classOf[ZoneVehicleActor], zone, zone.vehicles, zone.linkDynamicTurretWeapon), s"$id-vehicles") - zone.population = context.actorOf(Props(classOf[ZonePopulationActor], zone, zone.players, zone.corpses), s"$id-players") + zone.population = context.actorOf(Props(classOf[ZonePopulationActor], zone, zone.players, zone.bots, zone.corpses), s"$id-players") zone.projector = context.actorOf( Props(classOf[ZoneHotSpotDisplay], zone, zone.hotspots, 15 seconds, zone.hotspotHistory, 60 seconds), s"$id-hotspots" ) zone.soi = context.actorOf(Props(classOf[SphereOfInfluenceActor], zone), s"$id-soi") + //enable target spawns for VR Shooting Range zones + if (zone.id.startsWith("tzsh")) { + zone.npcPopulation = context.actorOf(Props(classOf[ShootingRangeTargetSpawnerActor], zone), s"$id-npcs") + } + zone.avatarEvents = context.actorOf(Props(classOf[AvatarService], zone), s"$id-avatar-events") zone.localEvents = context.actorOf(Props(classOf[LocalService], zone), s"$id-local-events") zone.vehicleEvents = context.actorOf(Props(classOf[VehicleService], zone), s"$id-vehicle-events") @@ -1943,9 +1976,14 @@ object Zone { val allAffectedTargets = pssos.filter { target => testTargetsFromZone(source, target, radius) } //inform remaining targets that they have suffered damage allAffectedTargets - .foreach { target => - if (zone.id.startsWith("tz") && source.Faction == target.Faction) { - //do not perform friendly-fire in VR zones + .foreach { target => + if (target.IsInVRZone) { + //disable all server-side damage in VR zones, unless the target is a bot in the VR Shooting Range + target match { + case bot: AvatarBot => + target.Actor ! Vitality.Damage(createInteraction(source, target).calculate()) + case _ => + } } else { target.Actor ! Vitality.Damage(createInteraction(source, target).calculate()) } @@ -2107,6 +2145,8 @@ object Zone { //collect all targets that can be damaged //players val playerTargets = sector.livePlayerList.filterNot { _.VehicleSeated.nonEmpty } + //bots + val botTargets = sector.botList //vehicles val vehicleTargets = sector.vehicleList.filterNot { v => v.Destroyed || v.MountedIn.nonEmpty } //deployables @@ -2114,7 +2154,7 @@ object Zone { //amenities val soiTargets = sector.amenityList.collect { case amenity: Vitality if !amenity.Destroyed => amenity } //altogether ... - playerTargets ++ vehicleTargets ++ deployableTargets ++ soiTargets + playerTargets ++ botTargets ++ vehicleTargets ++ deployableTargets ++ soiTargets } /** @@ -2140,6 +2180,8 @@ object Zone { //collect all targets that can be damaged //players val playerTargets = sector.livePlayerList.filter { player => player.VehicleSeated.isEmpty && player.WhichSide == Sidedness.OutsideOf } + //bots + val botTargets = sector.botList.filter { bot => bot.WhichSide == Sidedness.OutsideOf } //vehicles val vehicleTargets = sector.vehicleList.filterNot { _.Destroyed } //deployables @@ -2148,7 +2190,7 @@ object Zone { val soiTargets = sector.amenityList.collect { case amenity: Vitality if !amenity.Destroyed && amenity.WhichSide == Sidedness.OutsideOf && amenity.CanDamage => amenity } //altogether ... - playerTargets ++ vehicleTargets ++ deployableTargets ++ soiTargets + playerTargets ++ botTargets ++ vehicleTargets ++ deployableTargets ++ soiTargets } /** diff --git a/src/main/scala/net/psforever/objects/zones/ZonePopulationActor.scala b/src/main/scala/net/psforever/objects/zones/ZonePopulationActor.scala index f7480a8a6..e972808d6 100644 --- a/src/main/scala/net/psforever/objects/zones/ZonePopulationActor.scala +++ b/src/main/scala/net/psforever/objects/zones/ZonePopulationActor.scala @@ -3,17 +3,18 @@ package net.psforever.objects.zones import akka.actor.{Actor, ActorRef, Props} import net.psforever.actors.zone.ZoneActor -import net.psforever.objects.avatar.{CorpseControl, PlayerControl} +import net.psforever.objects.avatar.{AvatarBot, CorpseControl, PlayerControl} import net.psforever.objects.sourcing.PlayerSource import net.psforever.objects.vital.{InGameHistory, SpawningActivity} import net.psforever.objects.{Default, Player} +import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage} import net.psforever.types.Vector3 import scala.collection.concurrent.TrieMap import scala.collection.mutable.ListBuffer /** - * A support `Actor` that sequences adding and removing `Avatar` and `Player` objects to mappings and lists. + * A support `Actor` that sequences adding and removing `Avatar`, `AvatarBot`, and `Player` objects to mappings and lists. * The former mapping is considered to represent every user connect to the `zone` (`as Avatar` objects) * and their current representation (as `Player` objects). * The latter list keeps track of a group of former user representations. @@ -22,7 +23,7 @@ import scala.collection.mutable.ListBuffer * @param playerMap the mapping of `Avatar` objects to `Player` objects * @param corpseList a list of `Player` objects */ -class ZonePopulationActor(zone: Zone, playerMap: TrieMap[Int, Option[Player]], corpseList: ListBuffer[Player]) +class ZonePopulationActor(zone: Zone, playerMap: TrieMap[Int, Option[Player]], botList: ListBuffer[AvatarBot], corpseList: ListBuffer[Player]) extends Actor { import ZonePopulationActor._ @@ -83,6 +84,27 @@ class ZonePopulationActor(zone: Zone, playerMap: TrieMap[Int, Option[Player]], c sender() ! Zone.Population.PlayerHasLeft(zone, None) } + case Zone.Bots.Spawn(bot) => + if (BotSpawn(bot, botList)) { + bot.Zone = zone + zone.actor ! ZoneActor.AddToBlockMap(bot, bot.Position) + zone.AvatarEvents ! AvatarServiceMessage( + zone.id, + AvatarAction.LoadPlayer(bot.GUID, bot.Definition.ObjectId, bot.GUID, bot.Definition.Packet.ConstructorData(bot).get, None) + ) + } + + case Zone.Bots.Release(bot) => + if (BotRelease(bot, botList)) { + if (bot.Actor != null) bot.Actor ! akka.actor.PoisonPill + bot.Actor = Default.Actor + zone.actor ! ZoneActor.RemoveFromBlockMap(bot) + zone.AvatarEvents ! AvatarServiceMessage( + zone.id, + AvatarAction.ObjectDelete(bot.GUID, bot.GUID, unk=0) + ) + } + case Zone.Corpse.Add(player) => //player can be a corpse if they are in the current zone or are not in any zone //player is "found" if their avatar can be matched by name within this zone and it has a character @@ -202,6 +224,31 @@ object ZonePopulationActor { } } + /** + * Add the given `AvatarBot` to the list of bots. + * @param bot an `AvatarBot` object + * @param botList a list of `AvatarBot` objects + */ + def BotSpawn(bot: AvatarBot, botList: ListBuffer[AvatarBot]): Boolean = { + botList += bot + true + } + + /** + * Remove the given `AvatarBot` from the list of bots. + * @param bot an `AvatarBot` object + * @param botList a list of `AvatarBot` objects + */ + def BotRelease(bot: AvatarBot, botList: ListBuffer[AvatarBot]): Boolean = { + botList.indexOf(bot) match { + case -1 => + false + case index => + botList.remove(index) + true + } + } + /** * If the given `player` passes a condition check, add it to the list. * @param player a `Player` object diff --git a/src/main/scala/net/psforever/objects/zones/blockmap/Sector.scala b/src/main/scala/net/psforever/objects/zones/blockmap/Sector.scala index cbaf923cb..8e8b8ba95 100644 --- a/src/main/scala/net/psforever/objects/zones/blockmap/Sector.scala +++ b/src/main/scala/net/psforever/objects/zones/blockmap/Sector.scala @@ -7,6 +7,7 @@ import net.psforever.objects.equipment.Equipment import net.psforever.objects.serverobject.environment.PieceOfEnvironment import net.psforever.objects.serverobject.structures.{Amenity, Building} import net.psforever.objects.{Player, Vehicle} +import net.psforever.objects.avatar.AvatarBot import scala.collection.mutable.ListBuffer @@ -20,6 +21,8 @@ trait SectorPopulation { def livePlayerList: List[Player] + def botList: List[AvatarBot] + def corpseList: List[Player] def vehicleList: List[Vehicle] @@ -41,6 +44,7 @@ trait SectorPopulation { */ def total: Int = { livePlayerList.size + + botList.size + corpseList.size + vehicleList.size + equipmentOnGroundList.size + @@ -123,6 +127,10 @@ class Sector(val longitude: Int, val latitude: Int, val span: Int) (a: Player, b: Player) => a.GUID == b.GUID ) + private val bots: SectorListOf[AvatarBot] = new SectorListOf[AvatarBot]( + (a: AvatarBot, b: AvatarBot) => a.GUID == b.GUID + ) + private val corpses: SectorListOf[Player] = new SectorListOf[Player]( (a: Player, b: Player) => a eq b ) @@ -161,6 +169,8 @@ class Sector(val longitude: Int, val latitude: Int, val span: Int) def livePlayerList : List[Player] = livePlayers.list + def botList : List[AvatarBot] = bots.list + def corpseList: List[Player] = corpses.list def vehicleList: List[Vehicle] = vehicles.list @@ -191,6 +201,8 @@ class Sector(val longitude: Int, val latitude: Int, val span: Int) corpses.list.size < corpses.addTo(p).size case p: Player => livePlayers.list.size < livePlayers.addTo(p).size + case ab: AvatarBot => + bots.list.size < bots.addTo(ab).size case v: Vehicle => vehicles.list.size < vehicles.addTo(v).size case e: Equipment => @@ -226,6 +238,8 @@ class Sector(val longitude: Int, val latitude: Int, val span: Int) corpses.list.size > corpses.removeFrom(p).size case p: Player => livePlayers.list.size > livePlayers.removeFrom(p).size + case ab: AvatarBot => + bots.list.size > bots.removeFrom(ab).size case v: Vehicle => vehicles.list.size > vehicles.removeFrom(v).size case e: Equipment => @@ -253,6 +267,7 @@ object Sector { * The specific datastructure that is mentioned when using the term "sector conglomerate". * Typically used to compose the lists of entities from various individual sectors. * @param livePlayerList the living players + * @param botList the bot avatars * @param corpseList the dead players * @param vehicleList vehicles * @param equipmentOnGroundList dropped equipment @@ -265,6 +280,7 @@ class SectorGroup( val rangeX: Float, val rangeY: Float, val livePlayerList: List[Player], + val botList: List[AvatarBot], val corpseList: List[Player], val vehicleList: List[Vehicle], val equipmentOnGroundList: List[Equipment], @@ -314,6 +330,7 @@ object SectorGroup { rangeX, rangeY, sector.livePlayerList.filterNot(p => p.spectator || !p.allowInteraction), + sector.botList.filterNot(b => !b.allowInteraction), sector.corpseList, sector.vehicleList, sector.equipmentOnGroundList, @@ -362,13 +379,14 @@ object SectorGroup { */ def apply(rangeX: Float, rangeY: Float, sectors: Iterable[Sector]): SectorGroup = { if (sectors.isEmpty) { - new SectorGroup(rangeX, rangeY, Nil, Nil, Nil, Nil, Nil, Nil, Nil, Nil, Nil) + new SectorGroup(rangeX, rangeY, Nil, Nil, Nil, Nil, Nil, Nil, Nil, Nil, Nil, Nil) } else if (sectors.size == 1) { val sector = sectors.head new SectorGroup( rangeX, rangeY, sector.livePlayerList.filterNot(p => p.spectator || !p.allowInteraction), + sector.botList.filterNot(b => !b.allowInteraction), sector.corpseList, sector.vehicleList, sector.equipmentOnGroundList, @@ -383,6 +401,7 @@ object SectorGroup { rangeX, rangeY, sectors.flatMap { _.livePlayerList }.toList.distinct.filterNot(p => p.spectator || !p.allowInteraction), + sectors.flatMap { _.botList }.toList.distinct.filterNot(b => !b.allowInteraction), sectors.flatMap { _.corpseList }.toList.distinct, sectors.flatMap { _.vehicleList }.toList.distinct, sectors.flatMap { _.equipmentOnGroundList }.toList.distinct, diff --git a/src/main/scala/net/psforever/packet/game/GenericActionMessage.scala b/src/main/scala/net/psforever/packet/game/GenericActionMessage.scala index 1a50426ce..1d89fbd66 100644 --- a/src/main/scala/net/psforever/packet/game/GenericActionMessage.scala +++ b/src/main/scala/net/psforever/packet/game/GenericActionMessage.scala @@ -38,6 +38,7 @@ object GenericAction extends IntEnum[GenericAction] { final case object FailToDeconstruct extends GenericAction(value = 33) final case object LookingForSquad_RCV extends GenericAction(value = 36) final case object NotLookingForSquad_RCV extends GenericAction(value = 37) + final case object TrainingGriefWarning extends GenericAction(value = 43) final case object Unknown45 extends GenericAction(value = 45) final case class Unknown(override val value: Int) extends GenericAction(value) diff --git a/src/main/scala/net/psforever/packet/game/TriggerBotAction.scala b/src/main/scala/net/psforever/packet/game/TriggerBotAction.scala new file mode 100644 index 000000000..83543d912 --- /dev/null +++ b/src/main/scala/net/psforever/packet/game/TriggerBotAction.scala @@ -0,0 +1,27 @@ +// Copyright (c) 2026 PSForever +package net.psforever.packet.game + +import net.psforever.packet.{GamePacketOpcode, Marshallable, PlanetSideGamePacket} +import net.psforever.types.PlanetSideGUID +import scodec.Codec +import scodec.codecs._ + +/** + * Dispatched from the server to cause a bot avatar to perform a random emote + * @param bot_guid the target bot's global unique identifier + */ +final case class TriggerBotAction(bot_guid: PlanetSideGUID, unk1: Long = 0, unk2: Long = 0, unk3: Long = 4294967295L) + extends PlanetSideGamePacket { + type Packet = TriggerBotAction + def opcode = GamePacketOpcode.TriggerBotAction + def encode = TriggerBotAction.encode(this) +} + +object TriggerBotAction extends Marshallable[TriggerBotAction] { + implicit val codec : Codec[TriggerBotAction] = ( + ("bot_guid" | PlanetSideGUID.codec) :: + ("unk1" | uint32L) :: + ("unk2" | uint32L) :: + ("unk3" | uint32L) + ).as[TriggerBotAction] +} diff --git a/src/main/scala/net/psforever/packet/game/objectcreate/ObjectClass.scala b/src/main/scala/net/psforever/packet/game/objectcreate/ObjectClass.scala index 481cd014b..9e78796a1 100644 --- a/src/main/scala/net/psforever/packet/game/objectcreate/ObjectClass.scala +++ b/src/main/scala/net/psforever/packet/game/objectcreate/ObjectClass.scala @@ -404,28 +404,37 @@ object ObjectClass { final val vulture = 986 final val wasp = 997 //other - final val ams_order_terminal = 48 - final val ams_respawn_tube = 49 - final val avatar = 121 - final val bfr_rearm_terminal = 142 - final val capture_flag = 157 - final val implant_terminal_interface = 409 - final val locker_container = 456 - final val lodestar_repair_terminal = 461 - final val manned_turret = 480 - final val matrix_terminala = 517 - final val matrix_terminalb = 518 - final val matrix_terminalc = 519 - final val multivehicle_rearm_terminal = 576 - final val order_terminal = 612 - final val order_terminala = 613 - final val order_terminalb = 614 - final val portable_ammo_terminal = 684 - final val portable_order_terminal = 690 - final val spawn_pad = 800 - final val spawn_zone = 815 - final val targeting_laser_dispenser = 851 - final val teleportpad_terminal = 853 + final val ams_order_terminal = 48 + final val ams_respawn_tube = 49 + final val avatar = 121 + final val avatar_bot = 122 + final val avatar_bot_agile = 123 + final val avatar_bot_agile_no_weapon = 124 + final val avatar_bot_max = 125 + final val avatar_bot_max_no_weapon = 126 + final val avatar_bot_reinforced = 127 + final val avatar_bot_reinforced_no_weapon = 128 + final val avatar_bot_standard = 129 + final val avatar_bot_standard_no_weapon = 130 + final val bfr_rearm_terminal = 142 + final val capture_flag = 157 + final val implant_terminal_interface = 409 + final val locker_container = 456 + final val lodestar_repair_terminal = 461 + final val manned_turret = 480 + final val matrix_terminala = 517 + final val matrix_terminalb = 518 + final val matrix_terminalc = 519 + final val multivehicle_rearm_terminal = 576 + final val order_terminal = 612 + final val order_terminala = 613 + final val order_terminalb = 614 + final val portable_ammo_terminal = 684 + final val portable_order_terminal = 690 + final val spawn_pad = 800 + final val spawn_zone = 815 + final val targeting_laser_dispenser = 851 + final val teleportpad_terminal = 853 // For property overrides final val delivererv = 239 @@ -664,8 +673,17 @@ object ObjectClass { case ObjectClass.router_telepad => ConstructorData(DetailedConstructionToolData.codec, "router telepad") case ObjectClass.boomer_trigger => ConstructorData(DetailedConstructionToolData.codec, "boomer trigger") //other - case ObjectClass.avatar => ConstructorData(DetailedPlayerData.codec(false), "avatar") - case ObjectClass.locker_container => ConstructorData(DetailedLockerContainerData.codec, "locker container") + case ObjectClass.avatar => ConstructorData(DetailedPlayerData.codec(false), "avatar") + case ObjectClass.avatar_bot => ConstructorData(DetailedPlayerData.codec(false), "avatar bot") + case ObjectClass.avatar_bot_agile => ConstructorData(DetailedPlayerData.codec(false), "avatar bot agile") + case ObjectClass.avatar_bot_agile_no_weapon => ConstructorData(DetailedPlayerData.codec(false), "avatar bot agile no weapon") + case ObjectClass.avatar_bot_max => ConstructorData(DetailedPlayerData.codec(false), "avatar bot max") + case ObjectClass.avatar_bot_max_no_weapon => ConstructorData(DetailedPlayerData.codec(false), "avatar bot max no weapon") + case ObjectClass.avatar_bot_reinforced => ConstructorData(DetailedPlayerData.codec(false), "avatar bot reinforced") + case ObjectClass.avatar_bot_reinforced_no_weapon => ConstructorData(DetailedPlayerData.codec(false), "avatar bot reinforced no weapon") + case ObjectClass.avatar_bot_standard => ConstructorData(DetailedPlayerData.codec(false), "avatar bot standard") + case ObjectClass.avatar_bot_standard_no_weapon => ConstructorData(DetailedPlayerData.codec(false), "avatar bot standard no weapon") + case ObjectClass.locker_container => ConstructorData(DetailedLockerContainerData.codec, "locker container") //failure case case _ => defaultFailureCodec(objClass) @@ -983,21 +1001,30 @@ object ObjectClass { //vehicles? case ObjectClass.orbital_shuttle => ConstructorData(OrbitalShuttleData.codec, "HART") //other - case ObjectClass.ams_order_terminal => ConstructorData(CommonFieldData.codec2, "terminal") - case ObjectClass.ams_respawn_tube => ConstructorData(CommonFieldData.codec2, "terminal") - case ObjectClass.avatar => ConstructorData(PlayerData.codec(false), "avatar") - case ObjectClass.bfr_rearm_terminal => ConstructorData(CommonFieldData.codec2, "terminal") - case ObjectClass.implant_terminal_interface => ConstructorData(CommonFieldData.codec2, "implant terminal") - case ObjectClass.lodestar_repair_terminal => ConstructorData(CommonFieldData.codec2, "terminal") - case ObjectClass.matrix_terminala => ConstructorData(CommonFieldData.codec2, "terminal") - case ObjectClass.matrix_terminalb => ConstructorData(CommonFieldData.codec2, "terminal") - case ObjectClass.matrix_terminalc => ConstructorData(CommonFieldData.codec2, "terminal") - case ObjectClass.multivehicle_rearm_terminal => ConstructorData(CommonFieldData.codec2, "terminal") - case ObjectClass.order_terminal => ConstructorData(CommonFieldData.codec2, "terminal") - case ObjectClass.order_terminala => ConstructorData(CommonFieldData.codec2, "terminal") - case ObjectClass.order_terminalb => ConstructorData(CommonFieldData.codec2, "terminal") - case ObjectClass.targeting_laser_dispenser => ConstructorData(CommonFieldData.codec2, "terminal") - case ObjectClass.teleportpad_terminal => ConstructorData(CommonFieldData.codec2, "terminal") + case ObjectClass.ams_order_terminal => ConstructorData(CommonFieldData.codec2, "terminal") + case ObjectClass.ams_respawn_tube => ConstructorData(CommonFieldData.codec2, "terminal") + case ObjectClass.avatar => ConstructorData(PlayerData.codec(false), "avatar") + case ObjectClass.avatar_bot => ConstructorData(PlayerData.codec(false), "avatar bot") + case ObjectClass.avatar_bot_agile => ConstructorData(PlayerData.codec(false), "avatar bot agile") + case ObjectClass.avatar_bot_agile_no_weapon => ConstructorData(PlayerData.codec(false), "avatar bot agile no weapon") + case ObjectClass.avatar_bot_max => ConstructorData(PlayerData.codec(false), "avatar bot max") + case ObjectClass.avatar_bot_max_no_weapon => ConstructorData(PlayerData.codec(false), "avatar bot max no weapon") + case ObjectClass.avatar_bot_reinforced => ConstructorData(PlayerData.codec(false), "avatar bot reinforced") + case ObjectClass.avatar_bot_reinforced_no_weapon => ConstructorData(PlayerData.codec(false), "avatar bot reinforced no weapon") + case ObjectClass.avatar_bot_standard => ConstructorData(PlayerData.codec(false), "avatar bot standard") + case ObjectClass.avatar_bot_standard_no_weapon => ConstructorData(PlayerData.codec(false), "avatar bot standard no weapon") + case ObjectClass.bfr_rearm_terminal => ConstructorData(CommonFieldData.codec2, "terminal") + case ObjectClass.implant_terminal_interface => ConstructorData(CommonFieldData.codec2, "implant terminal") + case ObjectClass.lodestar_repair_terminal => ConstructorData(CommonFieldData.codec2, "terminal") + case ObjectClass.matrix_terminala => ConstructorData(CommonFieldData.codec2, "terminal") + case ObjectClass.matrix_terminalb => ConstructorData(CommonFieldData.codec2, "terminal") + case ObjectClass.matrix_terminalc => ConstructorData(CommonFieldData.codec2, "terminal") + case ObjectClass.multivehicle_rearm_terminal => ConstructorData(CommonFieldData.codec2, "terminal") + case ObjectClass.order_terminal => ConstructorData(CommonFieldData.codec2, "terminal") + case ObjectClass.order_terminala => ConstructorData(CommonFieldData.codec2, "terminal") + case ObjectClass.order_terminalb => ConstructorData(CommonFieldData.codec2, "terminal") + case ObjectClass.targeting_laser_dispenser => ConstructorData(CommonFieldData.codec2, "terminal") + case ObjectClass.teleportpad_terminal => ConstructorData(CommonFieldData.codec2, "terminal") //failure case case _ => defaultFailureCodec(objClass) } @@ -1324,17 +1351,26 @@ object ObjectClass { case ObjectClass.vulture => ConstructorData(VehicleData.codec(VehicleFormat.Variant), "vehicle") case ObjectClass.wasp => ConstructorData(VehicleData.codec(VehicleFormat.Variant), "vehicle") //other - case ObjectClass.ams_respawn_tube => DroppedItemData(CommonFieldData.codec2, "terminal") - case ObjectClass.avatar => ConstructorData(PlayerData.codec(true), "avatar") - case ObjectClass.capture_flag => ConstructorData(CaptureFlagData.codec, "capture flag") - case ObjectClass.implant_terminal_interface => ConstructorData(CommonFieldData.codec2, "implant terminal") - case ObjectClass.locker_container => ConstructorData(LockerContainerData.codec, "locker container") - case ObjectClass.matrix_terminala => DroppedItemData(CommonFieldData.codec2, "terminal") - case ObjectClass.matrix_terminalb => DroppedItemData(CommonFieldData.codec2, "terminal") - case ObjectClass.matrix_terminalc => DroppedItemData(CommonFieldData.codec2, "terminal") - case ObjectClass.order_terminal => DroppedItemData(CommonFieldData.codec2, "terminal") - case ObjectClass.order_terminala => DroppedItemData(CommonFieldData.codec2, "terminal") - case ObjectClass.order_terminalb => DroppedItemData(CommonFieldData.codec2, "terminal") + case ObjectClass.ams_respawn_tube => DroppedItemData(CommonFieldData.codec2, "terminal") + case ObjectClass.avatar => ConstructorData(PlayerData.codec(true), "avatar") + case ObjectClass.avatar_bot => ConstructorData(PlayerData.codec(true), "avatar bot") + case ObjectClass.avatar_bot_agile => ConstructorData(PlayerData.codec(true), "avatar bot agile") + case ObjectClass.avatar_bot_agile_no_weapon => ConstructorData(PlayerData.codec(true), "avatar bot agile no weapon") + case ObjectClass.avatar_bot_max => ConstructorData(PlayerData.codec(true), "avatar bot max") + case ObjectClass.avatar_bot_max_no_weapon => ConstructorData(PlayerData.codec(true), "avatar bot max no weapon") + case ObjectClass.avatar_bot_reinforced => ConstructorData(PlayerData.codec(true), "avatar bot reinforced") + case ObjectClass.avatar_bot_reinforced_no_weapon => ConstructorData(PlayerData.codec(true), "avatar bot reinforced no weapon") + case ObjectClass.avatar_bot_standard => ConstructorData(PlayerData.codec(true), "avatar bot standard") + case ObjectClass.avatar_bot_standard_no_weapon => ConstructorData(PlayerData.codec(true), "avatar bot standard no weapon") + case ObjectClass.capture_flag => ConstructorData(CaptureFlagData.codec, "capture flag") + case ObjectClass.implant_terminal_interface => ConstructorData(CommonFieldData.codec2, "implant terminal") + case ObjectClass.locker_container => ConstructorData(LockerContainerData.codec, "locker container") + case ObjectClass.matrix_terminala => DroppedItemData(CommonFieldData.codec2, "terminal") + case ObjectClass.matrix_terminalb => DroppedItemData(CommonFieldData.codec2, "terminal") + case ObjectClass.matrix_terminalc => DroppedItemData(CommonFieldData.codec2, "terminal") + case ObjectClass.order_terminal => DroppedItemData(CommonFieldData.codec2, "terminal") + case ObjectClass.order_terminala => DroppedItemData(CommonFieldData.codec2, "terminal") + case ObjectClass.order_terminalb => DroppedItemData(CommonFieldData.codec2, "terminal") //failure case case _ => defaultFailureCodec(objClass) } diff --git a/src/main/scala/net/psforever/util/Config.scala b/src/main/scala/net/psforever/util/Config.scala index ef1314248..c814904b6 100644 --- a/src/main/scala/net/psforever/util/Config.scala +++ b/src/main/scala/net/psforever/util/Config.scala @@ -158,6 +158,7 @@ case class GameConfig( baseCertifications: Seq[Certification], warpGates: WarpGateConfig, cavernRotation: CavernRotationConfig, + virtualTraining: VirtualTrainingConfig, savedMsg: SavedMessageEvents, playerDraw: PlayerStateDrawSettings, doorsCanBeOpenedByMedAppFromThisDistance: Float, @@ -218,6 +219,13 @@ case class CavernRotationConfig( forceRotationImmediately: Boolean ) +case class VirtualTrainingConfig( + shootingRangeTargetsEnabled: Boolean, + maleBotNames: List[String], + femaleBotNames: List[String], + universalBotNames: List[String] +) + case class SavedMessageEvents( short: SavedMessageTimings, renewal: SavedMessageTimings, From 236f851af8b5280d2b46941ec1fe2b34cd244baf Mon Sep 17 00:00:00 2001 From: Subsonic154 Date: Mon, 4 May 2026 06:38:35 -0400 Subject: [PATCH 02/12] oops --- src/main/resources/application.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index 8aed67ed5..86ac54b7f 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -23,7 +23,7 @@ world { ports = [] # The name of the server as displayed in the server browser. - server-name = "vr-shooting-targets" + server-name = "\\#1EACF9P\\#E87BE8S\\#F93F4E4ever" # How the server is displayed in the server browser. # One of: released beta development From bd6ca8b719e1e11c57eb738516afba9f8d9de5fc Mon Sep 17 00:00:00 2001 From: Subsonic154 Date: Mon, 4 May 2026 06:43:06 -0400 Subject: [PATCH 03/12] oops again --- ...eTargetSpawner.scala => ShootingRangeTargetSpawnerActor.scala} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/main/scala/net/psforever/actors/zone/{ShootingRangeTargetSpawner.scala => ShootingRangeTargetSpawnerActor.scala} (100%) diff --git a/src/main/scala/net/psforever/actors/zone/ShootingRangeTargetSpawner.scala b/src/main/scala/net/psforever/actors/zone/ShootingRangeTargetSpawnerActor.scala similarity index 100% rename from src/main/scala/net/psforever/actors/zone/ShootingRangeTargetSpawner.scala rename to src/main/scala/net/psforever/actors/zone/ShootingRangeTargetSpawnerActor.scala From efae7f6d8cbf03739a6563085f16340fbae539f5 Mon Sep 17 00:00:00 2001 From: Subsonic154 Date: Mon, 4 May 2026 08:49:26 -0400 Subject: [PATCH 04/12] Harden GetRandomBotName against exceptions --- .../ShootingRangeTargetSpawnerActor.scala | 55 +++++++++++-------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/src/main/scala/net/psforever/actors/zone/ShootingRangeTargetSpawnerActor.scala b/src/main/scala/net/psforever/actors/zone/ShootingRangeTargetSpawnerActor.scala index 790062c4a..7171bff30 100644 --- a/src/main/scala/net/psforever/actors/zone/ShootingRangeTargetSpawnerActor.scala +++ b/src/main/scala/net/psforever/actors/zone/ShootingRangeTargetSpawnerActor.scala @@ -95,7 +95,7 @@ class ShootingRangeTargetSpawnerActor(zone: Zone) extends Actor { private val activeInfantryTargets = ListBuffer[AvatarBot]() private val activeVehicleTargets = ListBuffer[Vehicle]() - private val botNamesInUse = ListBuffer[String]() + private var botNamesInUse = List[String]() override def preStart() = { if (Config.app.game.virtualTraining.shootingRangeTargetsEnabled) { @@ -112,7 +112,7 @@ class ShootingRangeTargetSpawnerActor(zone: Zone) extends Actor { target.Actor ! AvatarBot.Release() } activeInfantryTargets.clear() - botNamesInUse.clear() + botNamesInUse = List[String]() activeVehicleTargets.foreach{ target => if (target.Actor != Default.Actor) { target.Actor ! Vehicle.Deconstruct(None) @@ -209,27 +209,34 @@ class ShootingRangeTargetSpawnerActor(zone: Zone) extends Actor { * @return the name as a string */ private def GetRandomBotName(gender: CharacterSex): String = { - 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.addOne(name) - name - } else { - log.warn(s"Male bot name pool in ${zone.id} is empty!") - "Undefined" - } - case CharacterSex.Female => - val availableNames = (femaleOnlyBotNames ++ universalBotNames).filterNot(n => botNamesInUse.contains(n)) - if (!availableNames.isEmpty) { - val name = availableNames(Random.nextInt(availableNames.size)) - botNamesInUse.addOne(name) - name - } else { - log.warn(s"Female bot name pool in ${zone.id} is empty!") - "Undefined" - } + 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" } } @@ -291,7 +298,7 @@ class ShootingRangeTargetSpawnerActor(zone: Zone) extends Actor { //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.remove(index) + case index => botNamesInUse = botNamesInUse.filterNot(n => n == bot.Name) } true } From 0d2625d5383f95d790d44dc13cab7a17f7dfa80d Mon Sep 17 00:00:00 2001 From: Subsonic154 Date: Mon, 4 May 2026 09:17:23 -0400 Subject: [PATCH 05/12] Disable grief warning for splash damage against VR terminals --- .../net/psforever/actors/session/support/SessionData.scala | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/scala/net/psforever/actors/session/support/SessionData.scala b/src/main/scala/net/psforever/actors/session/support/SessionData.scala index 3af1b649b..b1b4d4f82 100644 --- a/src/main/scala/net/psforever/actors/session/support/SessionData.scala +++ b/src/main/scala/net/psforever/actors/session/support/SessionData.scala @@ -22,6 +22,7 @@ import net.psforever.objects.serverobject.mount.Mountable import net.psforever.objects.serverobject.structures.Amenity import net.psforever.objects.vehicles._ import net.psforever.objects.vital._ +import net.psforever.objects.vital.base.DamageResolution import net.psforever.objects.vital.interaction.DamageInteraction import net.psforever.objects.zones._ import net.psforever.objects.zones.blockmap.{BlockMap, BlockMapEntity, SectorGroup, SectorPopulation} @@ -461,7 +462,9 @@ class SessionData( case obj: Amenity if obj.CanDamage => if (obj.IsInVRZone && obj.Faction == player.Faction) { //disable friendly-fire in VR zones - general.trainingGriefWarning() + if (data.resolution != DamageResolution.Splash) { //don't do the grief warning against amenities from splash damage + general.trainingGriefWarning() + } } else { obj.Actor ! Vitality.Damage(func) } From 1f193a2ab0700e3b8fc462112f5137de92783b21 Mon Sep 17 00:00:00 2001 From: Subsonic154 Date: Mon, 4 May 2026 11:07:49 -0400 Subject: [PATCH 06/12] Store vehicle spawn location in vehicle list --- .../ShootingRangeTargetSpawnerActor.scala | 23 ++++++++++--------- .../vehicles/control/VehicleControl.scala | 2 +- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/main/scala/net/psforever/actors/zone/ShootingRangeTargetSpawnerActor.scala b/src/main/scala/net/psforever/actors/zone/ShootingRangeTargetSpawnerActor.scala index 7171bff30..14cc51557 100644 --- a/src/main/scala/net/psforever/actors/zone/ShootingRangeTargetSpawnerActor.scala +++ b/src/main/scala/net/psforever/actors/zone/ShootingRangeTargetSpawnerActor.scala @@ -20,7 +20,7 @@ import scala.util.Random object ShootingRangeTargetSpawner { final case class InfantryTargetReleased(bot: AvatarBot) - final case class VehicleTargetDeconstructed(vehicle: Vehicle, position: Vector3) + final case class VehicleTargetDeconstructed(vehicle: Vehicle) } class ShootingRangeTargetSpawnerActor(zone: Zone) extends Actor { @@ -94,7 +94,7 @@ class ShootingRangeTargetSpawnerActor(zone: Zone) extends Actor { ) private val activeInfantryTargets = ListBuffer[AvatarBot]() - private val activeVehicleTargets = ListBuffer[Vehicle]() + private val activeVehicleTargets = ListBuffer[(Vehicle, Vector3)]() private var botNamesInUse = List[String]() override def preStart() = { @@ -113,7 +113,7 @@ class ShootingRangeTargetSpawnerActor(zone: Zone) extends Actor { } activeInfantryTargets.clear() botNamesInUse = List[String]() - activeVehicleTargets.foreach{ target => + activeVehicleTargets.foreach{ case (target, pos) => if (target.Actor != Default.Actor) { target.Actor ! Vehicle.Deconstruct(None) } @@ -125,8 +125,8 @@ class ShootingRangeTargetSpawnerActor(zone: Zone) extends Actor { case ShootingRangeTargetSpawner.InfantryTargetReleased(bot) => RemoveBot(bot) - case ShootingRangeTargetSpawner.VehicleTargetDeconstructed(vehicle, pos) => - OnVehicleTargetDeconstructed(vehicle, pos) + case ShootingRangeTargetSpawner.VehicleTargetDeconstructed(vehicle) => + OnVehicleTargetDeconstructed(vehicle) case _ => () } @@ -357,7 +357,7 @@ class ShootingRangeTargetSpawnerActor(zone: Zone) extends Actor { localVehicle.Definition.Packet.ConstructorData(localVehicle).get ) ) - activeVehicleTargets.addOne(localVehicle) + activeVehicleTargets.addOne((localVehicle, localVehicle.Position)) log.debug(s"Spawned a ${localVehicle.Faction} ${localVehicle.Definition.Name} in ${zone.id} at ${localVehicle.Position}") Future(true) } @@ -366,15 +366,16 @@ class ShootingRangeTargetSpawnerActor(zone: Zone) extends Actor { ) } - private def OnVehicleTargetDeconstructed(vehicle: Vehicle, position: Vector3): Unit = { - activeVehicleTargets.indexOf(vehicle) match { - case -1 => - case index => + 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(position, vehicle.Orientation.z, GlobalDefinitions.isFlightVehicle(vehicle.Definition)) } + new Runnable() { override def run(): Unit = CreateVehicleTarget(pos, vehicle.Orientation.z, GlobalDefinitions.isFlightVehicle(vehicle.Definition)) } ) + case None => } } } diff --git a/src/main/scala/net/psforever/objects/vehicles/control/VehicleControl.scala b/src/main/scala/net/psforever/objects/vehicles/control/VehicleControl.scala index 3c196b71c..adf02e282 100644 --- a/src/main/scala/net/psforever/objects/vehicles/control/VehicleControl.scala +++ b/src/main/scala/net/psforever/objects/vehicles/control/VehicleControl.scala @@ -338,7 +338,7 @@ class VehicleControl(vehicle: Vehicle) //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, vehicle.Position), zone.NPCPopulation) + zone.NPCPopulation.tell(ShootingRangeTargetSpawner.VehicleTargetDeconstructed(vehicle), zone.NPCPopulation) } //unregister TaskWorkflow.execute(GUIDTask.unregisterVehicle(zone.GUID, vehicle)) From fdec30bea0582583744aa2e049f590956694ea0a Mon Sep 17 00:00:00 2001 From: Subsonic154 Date: Mon, 4 May 2026 16:22:25 -0400 Subject: [PATCH 07/12] Remove VR friendly-fire invulnerability from deployables --- .../psforever/actors/session/support/SessionData.scala | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/main/scala/net/psforever/actors/session/support/SessionData.scala b/src/main/scala/net/psforever/actors/session/support/SessionData.scala index b1b4d4f82..ef4a2daa4 100644 --- a/src/main/scala/net/psforever/actors/session/support/SessionData.scala +++ b/src/main/scala/net/psforever/actors/session/support/SessionData.scala @@ -477,14 +477,7 @@ class SessionData( } else { log.info(s"$name is attacking $ownerName's ${obj.Definition.Name}") } - if (obj.IsInVRZone && obj.Faction == player.Faction) { - //disable friendly-fire in VR zones - if (!ownerName.equals(name)) { - general.trainingGriefWarning() - } - } else { - obj.Actor ! Vitality.Damage(func) - } + obj.Actor ! Vitality.Damage(func) case _ => () } From 51b8956869324762cf7b14d958cf898109b944c6 Mon Sep 17 00:00:00 2001 From: Subsonic154 Date: Tue, 5 May 2026 06:43:47 -0400 Subject: [PATCH 08/12] Remove missed reduntant code in AvatarBotActor --- .../objects/avatar/AvatarBotActor.scala | 52 +------------------ 1 file changed, 1 insertion(+), 51 deletions(-) diff --git a/src/main/scala/net/psforever/objects/avatar/AvatarBotActor.scala b/src/main/scala/net/psforever/objects/avatar/AvatarBotActor.scala index 69fdd1444..3fffb1a99 100644 --- a/src/main/scala/net/psforever/objects/avatar/AvatarBotActor.scala +++ b/src/main/scala/net/psforever/objects/avatar/AvatarBotActor.scala @@ -16,8 +16,7 @@ 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.sourcing.PlayerSource -import net.psforever.objects.vital.collision.CollisionReason -import net.psforever.objects.vital.etc.{PainboxReason, SuicideReason} +import net.psforever.objects.vital.etc.SuicideReason import net.psforever.objects.vital.interaction.{DamageInteraction, DamageResult} import java.util.concurrent.{Executors, TimeUnit} @@ -203,55 +202,6 @@ class AvatarBotActor(bot: AvatarBot, spawnerActor: ActorRef) } else { //activity on map zone.Activity ! Zone.HotSpot.Activity(cause) - //alert to damage source - cause.adversarial match { - case Some(adversarial) => - adversarial.attacker match { - case pSource: PlayerSource => //bot damage - val name = pSource.Name - zone.LivePlayers.find(_.Name == name).orElse(zone.Corpses.find(_.Name == name)) match { - case Some(tplayer) => - zone.AvatarEvents ! AvatarServiceMessage( - target.Name, - AvatarAction.HitHint(tplayer.GUID, target.GUID) - ) - case None => - zone.AvatarEvents ! AvatarServiceMessage( - target.Name, - AvatarAction.SendResponse( - targetGUID, - DamageWithPositionMessage(countableDamage, pSource.Position) - ) - ) - } - case source => - zone.AvatarEvents ! AvatarServiceMessage( - target.Name, - AvatarAction.SendResponse( - targetGUID, - DamageWithPositionMessage(countableDamage, source.Position) - ) - ) - } - case None => - cause.interaction.cause match { - case o: PainboxReason => - zone.AvatarEvents ! AvatarServiceMessage( - target.Name, - AvatarAction.EnvironmentalDamage(target.GUID, o.entity.GUID, countableDamage) - ) - case _: CollisionReason => - events ! AvatarServiceMessage( - zoneId, - AvatarAction.SendResponse(targetGUID, AggravatedDamageMessage(targetGUID, countableDamage)) - ) - case _ => - zone.AvatarEvents ! AvatarServiceMessage( - target.Name, - AvatarAction.EnvironmentalDamage(target.GUID, ValidPlanetSideGUID(0), countableDamage) - ) - } - } } } } From 42726fded298910b07c34cf6e5713933e5fd4dcc Mon Sep 17 00:00:00 2001 From: Subsonic154 Date: Tue, 5 May 2026 11:30:34 -0400 Subject: [PATCH 09/12] Disable friendly-fire warning for amenities in VR --- .../net/psforever/actors/session/support/SessionData.scala | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/scala/net/psforever/actors/session/support/SessionData.scala b/src/main/scala/net/psforever/actors/session/support/SessionData.scala index ef4a2daa4..c72427aa3 100644 --- a/src/main/scala/net/psforever/actors/session/support/SessionData.scala +++ b/src/main/scala/net/psforever/actors/session/support/SessionData.scala @@ -462,9 +462,6 @@ class SessionData( case obj: Amenity if obj.CanDamage => if (obj.IsInVRZone && obj.Faction == player.Faction) { //disable friendly-fire in VR zones - if (data.resolution != DamageResolution.Splash) { //don't do the grief warning against amenities from splash damage - general.trainingGriefWarning() - } } else { obj.Actor ! Vitality.Damage(func) } From c3a663000aa72b3d8ce96bcab89afa6692a6ff9f Mon Sep 17 00:00:00 2001 From: Subsonic154 Date: Tue, 5 May 2026 11:38:31 -0400 Subject: [PATCH 10/12] Remove now unused import --- .../scala/net/psforever/actors/session/support/SessionData.scala | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/scala/net/psforever/actors/session/support/SessionData.scala b/src/main/scala/net/psforever/actors/session/support/SessionData.scala index c72427aa3..33b838102 100644 --- a/src/main/scala/net/psforever/actors/session/support/SessionData.scala +++ b/src/main/scala/net/psforever/actors/session/support/SessionData.scala @@ -22,7 +22,6 @@ import net.psforever.objects.serverobject.mount.Mountable import net.psforever.objects.serverobject.structures.Amenity import net.psforever.objects.vehicles._ import net.psforever.objects.vital._ -import net.psforever.objects.vital.base.DamageResolution import net.psforever.objects.vital.interaction.DamageInteraction import net.psforever.objects.zones._ import net.psforever.objects.zones.blockmap.{BlockMap, BlockMapEntity, SectorGroup, SectorPopulation} From 5697c3a0718d354b498de9a4adf14e62fca4ae8d Mon Sep 17 00:00:00 2001 From: Subsonic154 Date: Thu, 7 May 2026 17:00:15 -0400 Subject: [PATCH 11/12] Fix log warning spam when trying to heal or repair a bot --- .../actors/session/csr/GeneralLogic.scala | 4 +- .../actors/session/normal/GeneralLogic.scala | 4 +- .../session/support/GeneralOperations.scala | 21 ++++ .../objects/avatar/AvatarBotActor.scala | 100 ++++++++++++++++++ 4 files changed, 127 insertions(+), 2 deletions(-) diff --git a/src/main/scala/net/psforever/actors/session/csr/GeneralLogic.scala b/src/main/scala/net/psforever/actors/session/csr/GeneralLogic.scala index 31ca715e4..4f168065b 100644 --- a/src/main/scala/net/psforever/actors/session/csr/GeneralLogic.scala +++ b/src/main/scala/net/psforever/actors/session/csr/GeneralLogic.scala @@ -5,7 +5,7 @@ import akka.actor.{ActorContext, ActorRef, typed} import net.psforever.actors.session.AvatarActor import net.psforever.actors.session.support.{GeneralFunctions, GeneralOperations, SessionData} import net.psforever.objects.{Account, BoomerDeployable, BoomerTrigger, ConstructionItem, GlobalDefinitions, LivePlayerList, Player, SensorDeployable, ShieldGeneratorDeployable, SpecialEmp, TelepadDeployable, Tool, TrapDeployable, TurretDeployable, Vehicle} -import net.psforever.objects.avatar.{Avatar, PlayerControl} +import net.psforever.objects.avatar.{Avatar, AvatarBot, PlayerControl} import net.psforever.objects.ballistics.Projectile import net.psforever.objects.ce.Deployable import net.psforever.objects.definition.{BasicDefinition, KitDefinition, SpecialExoSuitDefinition} @@ -286,6 +286,8 @@ class GeneralLogic(val ops: GeneralOperations, implicit val context: ActorContex ops.handleUseGeneralEntity(panel, equipment) case Some(obj: Player) => ops.handleUsePlayer(obj, equipment, pkt) + case Some(obj: AvatarBot) => + ops.handleUseBot(obj, equipment, pkt) case Some(locker: Locker) => ops.handleUseLocker(locker, equipment, pkt) case Some(gen: Generator) => diff --git a/src/main/scala/net/psforever/actors/session/normal/GeneralLogic.scala b/src/main/scala/net/psforever/actors/session/normal/GeneralLogic.scala index 823d09923..af2f9a693 100644 --- a/src/main/scala/net/psforever/actors/session/normal/GeneralLogic.scala +++ b/src/main/scala/net/psforever/actors/session/normal/GeneralLogic.scala @@ -6,7 +6,7 @@ import akka.actor.{ActorContext, ActorRef, typed} import net.psforever.actors.session.{AvatarActor, SessionActor} import net.psforever.actors.session.support.{GeneralFunctions, GeneralOperations, SessionData, SessionOutfitHandlers} import net.psforever.objects.{Account, BoomerDeployable, BoomerTrigger, ConstructionItem, GlobalDefinitions, LivePlayerList, Player, SensorDeployable, ShieldGeneratorDeployable, SpecialEmp, TelepadDeployable, Tool, TrapDeployable, TurretDeployable, Vehicle} -import net.psforever.objects.avatar.{Avatar, PlayerControl, SpecialCarry} +import net.psforever.objects.avatar.{Avatar, AvatarBot, PlayerControl, SpecialCarry} import net.psforever.objects.ballistics.Projectile import net.psforever.objects.ce.{Deployable, DeployedItem} import net.psforever.objects.definition.{BasicDefinition, KitDefinition, SpecialExoSuitDefinition} @@ -356,6 +356,8 @@ class GeneralLogic(val ops: GeneralOperations, implicit val context: ActorContex ops.handleUseGeneralEntity(panel, equipment) case Some(obj: Player) => ops.handleUsePlayer(obj, equipment, pkt) + case Some(obj: AvatarBot) => + ops.handleUseBot(obj, equipment, pkt) case Some(locker: Locker) => ops.handleUseLocker(locker, equipment, pkt) case Some(gen: Generator) => diff --git a/src/main/scala/net/psforever/actors/session/support/GeneralOperations.scala b/src/main/scala/net/psforever/actors/session/support/GeneralOperations.scala index 3d2a92e95..76459ff0a 100644 --- a/src/main/scala/net/psforever/actors/session/support/GeneralOperations.scala +++ b/src/main/scala/net/psforever/actors/session/support/GeneralOperations.scala @@ -1206,6 +1206,27 @@ class GeneralOperations( } } + def handleUseBot(obj: AvatarBot, equipment: Option[Equipment], msg: UseItemMessage): Unit = { + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_use") + if (msg.unk3) { + msg.object_id match { + case ObjectClass.avatar | ObjectClass.avatar_bot | ObjectClass.avatar_bot_agile | ObjectClass.avatar_bot_agile_no_weapon | + ObjectClass.avatar_bot_max | ObjectClass.avatar_bot_max_no_weapon | ObjectClass.avatar_bot_reinforced | + ObjectClass.avatar_bot_reinforced_no_weapon | ObjectClass.avatar_bot_standard | ObjectClass.avatar_bot_standard_no_weapon => + equipment match { + case Some(tool: Tool) if tool.Definition == GlobalDefinitions.bank => + obj.Actor ! CommonMessages.Use(player, equipment) + + case Some(tool: Tool) if tool.Definition == GlobalDefinitions.medicalapplicator => + obj.Actor ! CommonMessages.Use(player, equipment) + case _ => () + } + + case _ => + } + } + } + def handleUseLocker(locker: Locker, equipment: Option[Equipment], msg: UseItemMessage): Unit = { equipment match { case Some(item) => diff --git a/src/main/scala/net/psforever/objects/avatar/AvatarBotActor.scala b/src/main/scala/net/psforever/objects/avatar/AvatarBotActor.scala index 3fffb1a99..94bdebfb8 100644 --- a/src/main/scala/net/psforever/objects/avatar/AvatarBotActor.scala +++ b/src/main/scala/net/psforever/objects/avatar/AvatarBotActor.scala @@ -3,19 +3,24 @@ 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} @@ -83,6 +88,91 @@ class AvatarBotActor(bot: AvatarBot, spawnerActor: ActorRef) 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 _ => ; } @@ -404,6 +494,16 @@ class AvatarBotActor(bot: AvatarBot, spawnerActor: ActorRef) 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 From bf57630182b40eacff7edb7ed2ed189537f51238 Mon Sep 17 00:00:00 2001 From: Subsonic154 Date: Fri, 8 May 2026 12:47:22 -0400 Subject: [PATCH 12/12] Implement infantry target random look-around rotation --- .../zone/ShootingRangeTargetSpawnerActor.scala | 17 +++++++++++++---- .../objects/avatar/AvatarBotActor.scala | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/main/scala/net/psforever/actors/zone/ShootingRangeTargetSpawnerActor.scala b/src/main/scala/net/psforever/actors/zone/ShootingRangeTargetSpawnerActor.scala index 14cc51557..1325caf09 100644 --- a/src/main/scala/net/psforever/actors/zone/ShootingRangeTargetSpawnerActor.scala +++ b/src/main/scala/net/psforever/actors/zone/ShootingRangeTargetSpawnerActor.scala @@ -141,8 +141,8 @@ class ShootingRangeTargetSpawnerActor(zone: Zone) extends Actor { } 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, yaw, false)} - infantrySpawnsMAX.foreach{case (pos, yaw) => CreateInfantryTarget(pos, yaw, true)} + 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}") } @@ -154,7 +154,7 @@ class ShootingRangeTargetSpawnerActor(zone: Zone) extends Actor { * @param facingYaw the direction the target will be facing * @param isMAX if this target is a MAX unit */ - def CreateInfantryTarget(position: Vector3, facingYaw: Float, isMAX: Boolean): 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 @@ -178,6 +178,15 @@ class ShootingRangeTargetSpawnerActor(zone: Zone) extends Actor { 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) @@ -288,7 +297,7 @@ class ShootingRangeTargetSpawnerActor(zone: Zone) extends Actor { //spawn a replacement bot context.system.scheduler.scheduleOnce( 5.seconds, - new Runnable() { override def run(): Unit = CreateInfantryTarget(bot.Position, bot.Orientation.z, bot.ExoSuit == ExoSuitType.MAX) } + 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( diff --git a/src/main/scala/net/psforever/objects/avatar/AvatarBotActor.scala b/src/main/scala/net/psforever/objects/avatar/AvatarBotActor.scala index 94bdebfb8..486289d66 100644 --- a/src/main/scala/net/psforever/objects/avatar/AvatarBotActor.scala +++ b/src/main/scala/net/psforever/objects/avatar/AvatarBotActor.scala @@ -59,6 +59,7 @@ class AvatarBotActor(bot: AvatarBot, spawnerActor: ActorRef) /** suffocating, or regaining breath? */ var submergedCondition: Option[OxygenState] = None private var canEmote = false + private var canRotate = false override def postStop(): Unit = { EndAllEffects() @@ -371,6 +372,7 @@ class AvatarBotActor(bot: AvatarBot, spawnerActor: ActorRef) 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) } @@ -408,6 +410,20 @@ class AvatarBotActor(bot: AvatarBot, spawnerActor: ActorRef) 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(