diff --git a/server/src/main/resources/overrides/game_objects0.adb.lst b/server/src/main/resources/overrides/game_objects0.adb.lst index be23a5ae4..3286c890a 100644 --- a/server/src/main/resources/overrides/game_objects0.adb.lst +++ b/server/src/main/resources/overrides/game_objects0.adb.lst @@ -23,7 +23,7 @@ add_property boomer_trigger equiptime 500 add_property chainblade equiptime 250 add_property chainblade holstertime 250 add_property colossus_flight requirement_award0 false -add_property command_detonater allowed false +add_property command_detonater allowed true add_property command_detonater equiptime 500 add_property command_detonater holstertime 500 add_property cycler equiptime 600 diff --git a/src/main/scala/net/psforever/actors/session/ChatActor.scala b/src/main/scala/net/psforever/actors/session/ChatActor.scala index 624d88117..abeca12f4 100644 --- a/src/main/scala/net/psforever/actors/session/ChatActor.scala +++ b/src/main/scala/net/psforever/actors/session/ChatActor.scala @@ -5,6 +5,8 @@ import akka.actor.typed.{ActorRef, Behavior, PostStop, SupervisorStrategy} import akka.actor.typed.receptionist.Receptionist import akka.actor.typed.scaladsl.{ActorContext, Behaviors, StashBuffer} import akka.actor.typed.scaladsl.adapter._ +import net.psforever.actors.session.normal.NormalMode +import net.psforever.actors.session.spectator.SpectatorMode import net.psforever.actors.zone.ZoneActor import net.psforever.objects.sourcing.PlayerSource import net.psforever.objects.zones.ZoneInfo @@ -761,19 +763,14 @@ class ChatActor( sessionActor ! SessionActor.SendResponse(message.copy(contents = f"$speed%.3f")) case (CMT_TOGGLESPECTATORMODE, _, contents) if gmCommandAllowed => - val spectator = contents match { - case "on" => true - case "off" => false - case _ => !session.player.spectator + val currentSpectatorActivation = session.player.spectator + contents.toLowerCase() match { + case "on" | "o" | "" if !currentSpectatorActivation => + sessionActor ! SessionActor.SetMode(SpectatorMode) + case "off" | "of" if currentSpectatorActivation => + sessionActor ! SessionActor.SetMode(NormalMode) + case _ => () } - sessionActor ! SessionActor.SetSpectator(spectator) - sessionActor ! SessionActor.SendResponse(message.copy(contents = if (spectator) "on" else "off")) - sessionActor ! SessionActor.SendResponse( - message.copy( - messageType = UNK_227, - contents = if (spectator) "@SpectatorEnabled" else "@SpectatorDisabled" - ) - ) case (CMT_RECALL, _, _) => val errorMessage = session.zoningType match { diff --git a/src/main/scala/net/psforever/actors/session/SessionActor.scala b/src/main/scala/net/psforever/actors/session/SessionActor.scala index 2e0046e95..81a1fbe8f 100644 --- a/src/main/scala/net/psforever/actors/session/SessionActor.scala +++ b/src/main/scala/net/psforever/actors/session/SessionActor.scala @@ -2,12 +2,13 @@ package net.psforever.actors.session import akka.actor.{Actor, Cancellable, MDCContextAware, typed} +import net.psforever.actors.session.normal.NormalMode import org.joda.time.LocalDateTime import org.log4s.MDC + import scala.collection.mutable // import net.psforever.actors.net.MiddlewareActor -import net.psforever.actors.session.normal.NormalMode import net.psforever.actors.session.support.{ModeLogic, PlayerMode, SessionData} import net.psforever.objects.{Default, Player} import net.psforever.objects.avatar.Avatar @@ -19,8 +20,6 @@ import net.psforever.types.Vector3 object SessionActor { sealed trait Command - private[session] final case class PokeClient() - private[session] final case class ServerLoaded() private[session] final case class NewPlayerLoaded(tplayer: Player) @@ -69,6 +68,10 @@ object SessionActor { private[session] case object CharSavedMsg extends Command + final case object StartHeartbeat extends Command + + private final case object PokeClient extends Command + final case class SetMode(mode: PlayerMode) extends Command } @@ -96,12 +99,29 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con case _ if data.whenAllEventBusesLoaded() => context.become(inTheGame) logic = mode.setup(data) - startHeartbeat() buffer.foreach { self.tell(_, self) } //we forget the original sender, shouldn't be doing callbacks at this point buffer.clear() case _ => () } + private def inTheGame: Receive = { + /* used for the game's heartbeat */ + case SessionActor.StartHeartbeat => + startHeartbeat() + + case SessionActor.PokeClient => + middlewareActor ! MiddlewareActor.Send(KeepAliveMessage()) + + case SessionActor.SetMode(newMode) => + logic.switchFrom(data.session) + mode = newMode + logic = mode.setup(data) + logic.switchTo(data.session) + + case packet => + logic.parse(sender())(packet) + } + private def startHeartbeat(): Unit = { import scala.concurrent.duration._ import scala.concurrent.ExecutionContext.Implicits.global @@ -110,20 +130,7 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con initialDelay = 0.seconds, delay = 500.milliseconds, context.self, - SessionActor.PokeClient() + SessionActor.PokeClient ) } - - private def inTheGame: Receive = { - /* used for the game's heartbeat */ - case SessionActor.PokeClient() => - middlewareActor ! MiddlewareActor.Send(KeepAliveMessage()) - - case SessionActor.SetMode(newMode) => - mode = newMode - logic = mode.setup(data) - - case packet => - logic.parse(sender())(packet) - } } diff --git a/src/main/scala/net/psforever/actors/session/normal/AvatarHandlerLogic.scala b/src/main/scala/net/psforever/actors/session/normal/AvatarHandlerLogic.scala index 89bdf3f66..0a84b1d3a 100644 --- a/src/main/scala/net/psforever/actors/session/normal/AvatarHandlerLogic.scala +++ b/src/main/scala/net/psforever/actors/session/normal/AvatarHandlerLogic.scala @@ -23,10 +23,14 @@ import net.psforever.services.Service import net.psforever.types.{ChatMessageType, PlanetSideGUID, TransactionType, Vector3} import net.psforever.util.Config -class AvatarHandlerLogic(val ops: SessionAvatarHandlers) extends AvatarHandlerFunctions { - def sessionLogic: SessionData = ops.sessionLogic +object AvatarHandlerLogic { + def apply(ops: SessionAvatarHandlers): AvatarHandlerLogic = { + new AvatarHandlerLogic(ops, ops.context) + } +} - implicit val context: ActorContext = ops.context +class AvatarHandlerLogic(val ops: SessionAvatarHandlers, implicit val context: ActorContext) extends AvatarHandlerFunctions { + def sessionLogic: SessionData = ops.sessionLogic private val avatarActor: typed.ActorRef[AvatarActor.Command] = ops.avatarActor diff --git a/src/main/scala/net/psforever/actors/session/normal/GalaxyHandlerLogic.scala b/src/main/scala/net/psforever/actors/session/normal/GalaxyHandlerLogic.scala index 69aac1a0d..f50d1e15b 100644 --- a/src/main/scala/net/psforever/actors/session/normal/GalaxyHandlerLogic.scala +++ b/src/main/scala/net/psforever/actors/session/normal/GalaxyHandlerLogic.scala @@ -8,10 +8,14 @@ import net.psforever.packet.game.{BroadcastWarpgateUpdateMessage, FriendsRespons import net.psforever.services.galaxy.{GalaxyAction, GalaxyResponse, GalaxyServiceMessage} import net.psforever.types.{MemberAction, PlanetSideEmpire} -class GalaxyHandlerLogic(val ops: SessionGalaxyHandlers) extends GalaxyHandlerFunctions { - def sessionLogic: SessionData = ops.sessionLogic +object GalaxyHandlerLogic { + def apply(ops: SessionGalaxyHandlers): GalaxyHandlerLogic = { + new GalaxyHandlerLogic(ops, ops.context) + } +} - implicit val context: ActorContext = ops.context +class GalaxyHandlerLogic(val ops: SessionGalaxyHandlers, implicit val context: ActorContext) extends GalaxyHandlerFunctions { + def sessionLogic: SessionData = ops.sessionLogic private val avatarActor: typed.ActorRef[AvatarActor.Command] = ops.avatarActor 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 4592f531d..78e6d9732 100644 --- a/src/main/scala/net/psforever/actors/session/normal/GeneralLogic.scala +++ b/src/main/scala/net/psforever/actors/session/normal/GeneralLogic.scala @@ -2,11 +2,11 @@ package net.psforever.actors.session.normal import akka.actor.typed.scaladsl.adapter._ -import akka.actor.{ActorContext, typed} -import net.psforever.actors.session.{AvatarActor, ChatActor} +import akka.actor.{ActorContext, Cancellable, typed} +import net.psforever.actors.session.{AvatarActor, ChatActor, SessionActor} import net.psforever.actors.session.support.{GeneralFunctions, GeneralOperations, SessionData} -import net.psforever.login.WorldSession.{CallBackForTask, ContainableMoveItem, DropEquipmentFromInventory, PickUpEquipmentFromGround, PutLoadoutEquipmentInInventory, RemoveOldEquipmentFromInventory} -import net.psforever.objects.{Account, BoomerDeployable, BoomerTrigger, ConstructionItem, Deployables, GlobalDefinitions, Kit, LivePlayerList, PlanetSideGameObject, Player, SensorDeployable, ShieldGeneratorDeployable, SpecialEmp, TelepadDeployable, Tool, TrapDeployable, TurretDeployable, Vehicle} +import net.psforever.login.WorldSession.{CallBackForTask, ContainableMoveItem, DropEquipmentFromInventory, PickUpEquipmentFromGround, RemoveOldEquipmentFromInventory} +import net.psforever.objects.{Account, BoomerDeployable, BoomerTrigger, ConstructionItem, Default, Deployables, GlobalDefinitions, Kit, LivePlayerList, PlanetSideGameObject, Player, SensorDeployable, ShieldGeneratorDeployable, SpecialEmp, TelepadDeployable, Tool, TrapDeployable, TurretDeployable, Vehicle} import net.psforever.objects.avatar.{Avatar, PlayerControl, SpecialCarry} import net.psforever.objects.ballistics.Projectile import net.psforever.objects.ce.{Deployable, DeployedItem, TelepadLike} @@ -14,7 +14,7 @@ import net.psforever.objects.definition.{BasicDefinition, KitDefinition, Special import net.psforever.objects.entity.WorldEntity import net.psforever.objects.equipment.Equipment import net.psforever.objects.guid.{GUIDTask, TaskBundle, TaskWorkflow} -import net.psforever.objects.inventory.{Container, InventoryItem} +import net.psforever.objects.inventory.Container import net.psforever.objects.serverobject.{CommonMessages, PlanetSideServerObject, ServerObject} import net.psforever.objects.serverobject.affinity.FactionAffinity import net.psforever.objects.serverobject.doors.Door @@ -51,10 +51,14 @@ import net.psforever.util.Config import scala.concurrent.duration._ -class GeneralLogic(val ops: GeneralOperations) extends GeneralFunctions { - def sessionLogic: SessionData = ops.sessionLogic +object GeneralLogic { + def apply(ops: GeneralOperations): GeneralLogic = { + new GeneralLogic(ops, ops.context) + } +} - implicit val context: ActorContext = ops.context +class GeneralLogic(val ops: GeneralOperations, implicit val context: ActorContext) extends GeneralFunctions { + def sessionLogic: SessionData = ops.sessionLogic private val avatarActor: typed.ActorRef[AvatarActor.Command] = ops.avatarActor @@ -66,6 +70,7 @@ class GeneralLogic(val ops: GeneralOperations) extends GeneralFunctions { s"ConnectToWorldRequestMessage: client with versioning $majorVersion.$minorVersion.$revision, $buildDate has sent a token to the server" ) sendResponse(ChatMsg(ChatMessageType.CMT_CULLWATERMARK, wideContents=false, "", "", None)) + context.self ! SessionActor.StartHeartbeat sessionLogic.accountIntermediary ! RetrieveAccountData(token) } @@ -989,7 +994,7 @@ class GeneralLogic(val ops: GeneralOperations) extends GeneralFunctions { } def handleKick(player: Player, time: Option[Long]): Unit = { - administrativeKick(player) + ops.administrativeKick(player) sessionLogic.accountPersistence ! AccountPersistenceService.Kick(player.Name, time) } @@ -999,31 +1004,6 @@ class GeneralLogic(val ops: GeneralOperations) extends GeneralFunctions { /* supporting functions */ - /** - * Enforce constraints on bulk purchases as determined by a given player's previous purchase times and hard acquisition delays. - * Intended to assist in sanitizing loadout information from the perspective of the player, or target owner. - * The equipment is expected to be unregistered and already fitted to their ultimate slot in the target container. - * @param player the player whose purchasing constraints are to be tested - * @param target the location in which the equipment will be stowed - * @param slots the equipment, in the standard object-slot format container - */ - def applyPurchaseTimersBeforePackingLoadout( - player: Player, - target: PlanetSideServerObject with Container, - slots: List[InventoryItem] - ): Unit = { - slots.foreach { item => - player.avatar.purchaseCooldown(item.obj.Definition) match { - case Some(_) => () - case None if Avatar.purchaseCooldowns.contains(item.obj.Definition) => - avatarActor ! AvatarActor.UpdatePurchaseTime(item.obj.Definition) - TaskWorkflow.execute(PutLoadoutEquipmentInInventory(target)(item.obj, item.start)) - case None => - TaskWorkflow.execute(PutLoadoutEquipmentInInventory(target)(item.obj, item.start)) - } - } - } - private def handleUseDoor(door: Door, equipment: Option[Equipment]): Unit = { equipment match { case Some(tool: Tool) if tool.Definition == GlobalDefinitions.medicalapplicator => @@ -1493,23 +1473,6 @@ class GeneralLogic(val ops: GeneralOperations) extends GeneralFunctions { } } - def administrativeKick(tplayer: Player): Unit = { - log.warn(s"${tplayer.Name} has been kicked by ${player.Name}") - tplayer.death_by = -1 - sessionLogic.accountPersistence ! AccountPersistenceService.Kick(tplayer.Name) - //get out of that vehicle - sessionLogic.vehicles.GetMountableAndSeat(None, tplayer, continent) match { - case (Some(obj), Some(seatNum)) => - tplayer.VehicleSeated = None - obj.Seats(seatNum).unmount(tplayer) - continent.VehicleEvents ! VehicleServiceMessage( - continent.id, - VehicleAction.KickPassenger(tplayer.GUID, seatNum, unk2=false, obj.GUID) - ) - case _ => () - } - } - private def updateCollisionHistoryForTarget( target: PlanetSideServerObject with Vitality with FactionAffinity, curr: Long diff --git a/src/main/scala/net/psforever/actors/session/normal/LocalHandlerLogic.scala b/src/main/scala/net/psforever/actors/session/normal/LocalHandlerLogic.scala index 04c845b43..b638b0d12 100644 --- a/src/main/scala/net/psforever/actors/session/normal/LocalHandlerLogic.scala +++ b/src/main/scala/net/psforever/actors/session/normal/LocalHandlerLogic.scala @@ -11,10 +11,14 @@ import net.psforever.services.Service import net.psforever.services.local.LocalResponse import net.psforever.types.{ChatMessageType, PlanetSideGUID, Vector3} -class LocalHandlerLogic(val ops: SessionLocalHandlers) extends LocalHandlerFunctions { - def sessionLogic: SessionData = ops.sessionLogic +object LocalHandlerLogic { + def apply(ops: SessionLocalHandlers): LocalHandlerLogic = { + new LocalHandlerLogic(ops, ops.context) + } +} - implicit val context: ActorContext = ops.context +class LocalHandlerLogic(val ops: SessionLocalHandlers, implicit val context: ActorContext) extends LocalHandlerFunctions { + def sessionLogic: SessionData = ops.sessionLogic /* response handlers */ diff --git a/src/main/scala/net/psforever/actors/session/normal/MountHandlerLogic.scala b/src/main/scala/net/psforever/actors/session/normal/MountHandlerLogic.scala index 6f8bf8d97..b4325d743 100644 --- a/src/main/scala/net/psforever/actors/session/normal/MountHandlerLogic.scala +++ b/src/main/scala/net/psforever/actors/session/normal/MountHandlerLogic.scala @@ -23,10 +23,14 @@ import net.psforever.types.{BailType, ChatMessageType, PlanetSideGUID, Vector3} import scala.concurrent.duration._ -class MountHandlerLogic(val ops: SessionMountHandlers) extends MountHandlerFunctions { - def sessionLogic: SessionData = ops.sessionLogic +object MountHandlerLogic { + def apply(ops: SessionMountHandlers): MountHandlerLogic = { + new MountHandlerLogic(ops, ops.context) + } +} - implicit val context: ActorContext = ops.context +class MountHandlerLogic(val ops: SessionMountHandlers, implicit val context: ActorContext) extends MountHandlerFunctions { + def sessionLogic: SessionData = ops.sessionLogic private val avatarActor: typed.ActorRef[AvatarActor.Command] = ops.avatarActor @@ -442,7 +446,7 @@ class MountHandlerLogic(val ops: SessionMountHandlers) extends MountHandlerFunct * @param obj the mountable object * @param seatNum the mount into which the player is mounting */ - def MountingAction(tplayer: Player, obj: PlanetSideGameObject with FactionAffinity with InGameHistory, seatNum: Int): Unit = { + private def MountingAction(tplayer: Player, obj: PlanetSideGameObject with FactionAffinity with InGameHistory, seatNum: Int): Unit = { val playerGuid: PlanetSideGUID = tplayer.GUID val objGuid: PlanetSideGUID = obj.GUID sessionLogic.actionsToCancel() @@ -461,7 +465,7 @@ class MountHandlerLogic(val ops: SessionMountHandlers) extends MountHandlerFunct * @param obj the mountable object * @param seatNum the mount out of which which the player is disembarking */ - def DismountVehicleAction(tplayer: Player, obj: PlanetSideGameObject with FactionAffinity with InGameHistory, seatNum: Int): Unit = { + private def DismountVehicleAction(tplayer: Player, obj: PlanetSideGameObject with FactionAffinity with InGameHistory, seatNum: Int): Unit = { DismountAction(tplayer, obj, seatNum) //until vehicles maintain synchronized momentum without a driver obj match { @@ -498,7 +502,7 @@ class MountHandlerLogic(val ops: SessionMountHandlers) extends MountHandlerFunct * @param obj the mountable object * @param seatNum the mount out of which which the player is disembarking */ - def DismountAction(tplayer: Player, obj: PlanetSideGameObject with FactionAffinity with InGameHistory, seatNum: Int): Unit = { + private def DismountAction(tplayer: Player, obj: PlanetSideGameObject with FactionAffinity with InGameHistory, seatNum: Int): Unit = { val playerGuid: PlanetSideGUID = tplayer.GUID tplayer.ContributionFrom(obj) sessionLogic.keepAliveFunc = sessionLogic.zoning.NormalKeepAlive diff --git a/src/main/scala/net/psforever/actors/session/normal/NormalMode.scala b/src/main/scala/net/psforever/actors/session/normal/NormalMode.scala index 1fdf22e9b..c85710b07 100644 --- a/src/main/scala/net/psforever/actors/session/normal/NormalMode.scala +++ b/src/main/scala/net/psforever/actors/session/normal/NormalMode.scala @@ -3,6 +3,8 @@ package net.psforever.actors.session.normal import akka.actor.Actor.Receive import akka.actor.ActorRef +import net.psforever.actors.session.support.{GeneralFunctions, LocalHandlerFunctions, MountHandlerFunctions, SquadHandlerFunctions, TerminalHandlerFunctions, VehicleFunctions, VehicleHandlerFunctions, WeaponAndProjectileFunctions} +import net.psforever.packet.game.UplinkRequest // import net.psforever.actors.session.{AvatarActor, SessionActor} import net.psforever.actors.session.support.{ModeLogic, PlayerMode, SessionData, ZoningOperations} @@ -29,16 +31,16 @@ import net.psforever.services.vehicle.VehicleServiceResponse import net.psforever.util.Config class NormalModeLogic(data: SessionData) extends ModeLogic { - val avatarResponse = new AvatarHandlerLogic(data.avatarResponse) - val galaxy = new GalaxyHandlerLogic(data.galaxyResponseHandlers) - val general = new GeneralLogic(data.general) - val local = new LocalHandlerLogic(data.localResponse) - val mountResponse = new MountHandlerLogic(data.mountResponse) - val shooting = new WeaponAndProjectileLogic(data.shooting) - val squad = new SquadHandlerLogic(data.squad) - val terminals = new TerminalHandlerLogic(data.terminals) - val vehicles = new VehicleLogic(data.vehicles) - val vehicleResponse = new VehicleHandlerLogic(data.vehicleResponseOperations) + val avatarResponse: AvatarHandlerLogic = AvatarHandlerLogic(data.avatarResponse) + val galaxy: GalaxyHandlerLogic = GalaxyHandlerLogic(data.galaxyResponseHandlers) + val general: GeneralFunctions = GeneralLogic(data.general) + val local: LocalHandlerFunctions = LocalHandlerLogic(data.localResponse) + val mountResponse: MountHandlerFunctions = MountHandlerLogic(data.mountResponse) + val shooting: WeaponAndProjectileFunctions = WeaponAndProjectileLogic(data.shooting) + val squad: SquadHandlerFunctions = SquadHandlerLogic(data.squad) + val terminals: TerminalHandlerFunctions = TerminalHandlerLogic(data.terminals) + val vehicles: VehicleFunctions = VehicleLogic(data.vehicles) + val vehicleResponse: VehicleHandlerFunctions = VehicleHandlerLogic(data.vehicleResponseOperations) def parse(sender: ActorRef): Receive = { /* really common messages (very frequently, every life) */ @@ -66,9 +68,6 @@ class NormalModeLogic(data: SessionData) extends ModeLogic { case VehicleServiceResponse(toChannel, guid, reply) => vehicleResponse.handle(toChannel, guid, reply) - case SessionActor.PokeClient() => - data.sendResponse(KeepAliveMessage()) - case SessionActor.SendResponse(packet) => data.sendResponse(packet) @@ -407,6 +406,8 @@ class NormalModeLogic(data: SessionData) extends ModeLogic { case packet: WeaponLazeTargetPositionMessage => shooting.handleWeaponLazeTargetPosition(packet) + case _: UplinkRequest => () + case packet: HitMessage => shooting.handleDirectHit(packet) diff --git a/src/main/scala/net/psforever/actors/session/normal/SquadHandlerLogic.scala b/src/main/scala/net/psforever/actors/session/normal/SquadHandlerLogic.scala index 35363ac8e..643f97958 100644 --- a/src/main/scala/net/psforever/actors/session/normal/SquadHandlerLogic.scala +++ b/src/main/scala/net/psforever/actors/session/normal/SquadHandlerLogic.scala @@ -12,10 +12,14 @@ import net.psforever.services.chat.ChatService import net.psforever.services.teamwork.{SquadResponse, SquadServiceMessage, SquadAction => SquadServiceAction} import net.psforever.types.{ChatMessageType, PlanetSideGUID, SquadListDecoration, SquadResponseType, WaypointSubtype} -class SquadHandlerLogic(val ops: SessionSquadHandlers) extends SquadHandlerFunctions { - def sessionLogic: SessionData = ops.sessionLogic +object SquadHandlerLogic { + def apply(ops: SessionSquadHandlers): SquadHandlerLogic = { + new SquadHandlerLogic(ops, ops.context) + } +} - implicit val context: ActorContext = ops.context +class SquadHandlerLogic(val ops: SessionSquadHandlers, implicit val context: ActorContext) extends SquadHandlerFunctions { + def sessionLogic: SessionData = ops.sessionLogic private val avatarActor: typed.ActorRef[AvatarActor.Command] = ops.avatarActor diff --git a/src/main/scala/net/psforever/actors/session/normal/TerminalHandlerLogic.scala b/src/main/scala/net/psforever/actors/session/normal/TerminalHandlerLogic.scala index 2c53b8d6d..5b6618562 100644 --- a/src/main/scala/net/psforever/actors/session/normal/TerminalHandlerLogic.scala +++ b/src/main/scala/net/psforever/actors/session/normal/TerminalHandlerLogic.scala @@ -14,10 +14,14 @@ import net.psforever.objects.vital.TerminalUsedActivity import net.psforever.packet.game.{FavoritesAction, FavoritesRequest, ItemTransactionMessage, ItemTransactionResultMessage, ProximityTerminalUseMessage, UnuseItemMessage} import net.psforever.types.{TransactionType, Vector3} -class TerminalHandlerLogic(val ops: SessionTerminalHandlers) extends TerminalHandlerFunctions { - def sessionLogic: SessionData = ops.sessionLogic +object TerminalHandlerLogic { + def apply(ops: SessionTerminalHandlers): TerminalHandlerLogic = { + new TerminalHandlerLogic(ops, ops.context) + } +} - implicit val context: ActorContext = ops.context +class TerminalHandlerLogic(val ops: SessionTerminalHandlers, implicit val context: ActorContext) extends TerminalHandlerFunctions { + def sessionLogic: SessionData = ops.sessionLogic private val avatarActor: typed.ActorRef[AvatarActor.Command] = ops.avatarActor diff --git a/src/main/scala/net/psforever/actors/session/normal/VehicleHandlerLogic.scala b/src/main/scala/net/psforever/actors/session/normal/VehicleHandlerLogic.scala index 58b466f54..6d8f9c1a4 100644 --- a/src/main/scala/net/psforever/actors/session/normal/VehicleHandlerLogic.scala +++ b/src/main/scala/net/psforever/actors/session/normal/VehicleHandlerLogic.scala @@ -15,10 +15,14 @@ import net.psforever.services.Service import net.psforever.services.vehicle.{VehicleResponse, VehicleServiceResponse} import net.psforever.types.{BailType, ChatMessageType, PlanetSideGUID, Vector3} -class VehicleHandlerLogic(val ops: SessionVehicleHandlers) extends VehicleHandlerFunctions { - def sessionLogic: SessionData = ops.sessionLogic +object VehicleHandlerLogic { + def apply(ops: SessionVehicleHandlers): VehicleHandlerLogic = { + new VehicleHandlerLogic(ops, ops.context) + } +} - implicit val context: ActorContext = ops.context +class VehicleHandlerLogic(val ops: SessionVehicleHandlers, implicit val context: ActorContext) extends VehicleHandlerFunctions { + def sessionLogic: SessionData = ops.sessionLogic private val avatarActor: typed.ActorRef[AvatarActor.Command] = ops.avatarActor diff --git a/src/main/scala/net/psforever/actors/session/normal/VehicleLogic.scala b/src/main/scala/net/psforever/actors/session/normal/VehicleLogic.scala index a7efd72cd..8d39fcc6b 100644 --- a/src/main/scala/net/psforever/actors/session/normal/VehicleLogic.scala +++ b/src/main/scala/net/psforever/actors/session/normal/VehicleLogic.scala @@ -14,10 +14,14 @@ import net.psforever.packet.game.{ChildObjectStateMessage, DeployRequestMessage, import net.psforever.services.vehicle.{VehicleAction, VehicleServiceMessage} import net.psforever.types.{DriveState, Vector3} -class VehicleLogic(val ops: VehicleOperations) extends VehicleFunctions { - def sessionLogic: SessionData = ops.sessionLogic +object VehicleLogic { + def apply(ops: VehicleOperations): VehicleLogic = { + new VehicleLogic(ops, ops.context) + } +} - implicit val context: ActorContext = ops.context +class VehicleLogic(val ops: VehicleOperations, implicit val context: ActorContext) extends VehicleFunctions { + def sessionLogic: SessionData = ops.sessionLogic private val avatarActor: typed.ActorRef[AvatarActor.Command] = ops.avatarActor @@ -330,11 +334,11 @@ class VehicleLogic(val ops: VehicleOperations) extends VehicleFunctions { * if and only if the vehicle is known to this client and the `WorldSessioNActor`-global `player` occupies it; * `(None, None)`, otherwise (even if the vehicle can be determined) */ - def GetMountableAndSeat( - direct: Option[PlanetSideGameObject with Mountable], - occupant: Player, - zone: Zone - ): (Option[PlanetSideGameObject with Mountable], Option[Int]) = + private def GetMountableAndSeat( + direct: Option[PlanetSideGameObject with Mountable], + occupant: Player, + zone: Zone + ): (Option[PlanetSideGameObject with Mountable], Option[Int]) = direct.orElse(zone.GUID(occupant.VehicleSeated)) match { case Some(obj: PlanetSideGameObject with Mountable) => obj.PassengerInSeat(occupant) match { @@ -347,26 +351,6 @@ class VehicleLogic(val ops: VehicleOperations) extends VehicleFunctions { (None, None) } - /** - * If the player is seated in a vehicle, find that vehicle and get the mount index number at which the player is sat.
- *
- * For special purposes involved in zone transfers, - * where the vehicle may or may not exist in either of the zones (yet), - * the value of `interstellarFerry` is also polled. - * Making certain this field is blanked after the transfer is completed is important - * to avoid inspecting the wrong vehicle and failing simple vehicle checks where this function may be employed. - * @see `GetMountableAndSeat` - * @see `interstellarFerry` - * @return a tuple consisting of a vehicle reference and a mount index - * if and only if the vehicle is known to this client and the `WorldSessioNActor`-global `player` occupies it; - * `(None, None)`, otherwise (even if the vehicle can be determined) - */ - def GetKnownVehicleAndSeat(): (Option[Vehicle], Option[Int]) = - GetMountableAndSeat(sessionLogic.zoning.interstellarFerry, player, continent) match { - case (Some(v: Vehicle), Some(seat)) => (Some(v), Some(seat)) - case _ => (None, None) - } - /** * If the player is seated in a vehicle, find that vehicle and get the mount index number at which the player is sat. * @see `GetMountableAndSeat` @@ -374,7 +358,7 @@ class VehicleLogic(val ops: VehicleOperations) extends VehicleFunctions { * if and only if the vehicle is known to this client and the `WorldSessioNActor`-global `player` occupies it; * `(None, None)`, otherwise (even if the vehicle can be determined) */ - def GetVehicleAndSeat(): (Option[Vehicle], Option[Int]) = + private def GetVehicleAndSeat(): (Option[Vehicle], Option[Int]) = GetMountableAndSeat(None, player, continent) match { case (Some(v: Vehicle), Some(seat)) => (Some(v), Some(seat)) case _ => (None, None) diff --git a/src/main/scala/net/psforever/actors/session/normal/WeaponAndProjectileLogic.scala b/src/main/scala/net/psforever/actors/session/normal/WeaponAndProjectileLogic.scala index e27926e20..38f398828 100644 --- a/src/main/scala/net/psforever/actors/session/normal/WeaponAndProjectileLogic.scala +++ b/src/main/scala/net/psforever/actors/session/normal/WeaponAndProjectileLogic.scala @@ -2,7 +2,7 @@ package net.psforever.actors.session.normal import akka.actor.{ActorContext, typed} -import net.psforever.actors.session.{AvatarActor, ChatActor} +import net.psforever.actors.session.AvatarActor import net.psforever.actors.session.support.{SessionData, WeaponAndProjectileFunctions, WeaponAndProjectileOperations} import net.psforever.login.WorldSession.{CountAmmunition, CountGrenades, FindAmmoBoxThatUses, FindEquipmentStock, FindToolThatUses, PutEquipmentInInventoryOrDrop, PutNewEquipmentInInventoryOrDrop, RemoveOldEquipmentFromInventory} import net.psforever.objects.ballistics.{Projectile, ProjectileQuality} @@ -26,7 +26,7 @@ import net.psforever.objects.vital.etc.OicwLilBuddyReason import net.psforever.objects.vital.interaction.DamageInteraction import net.psforever.objects.vital.projectile.ProjectileReason import net.psforever.objects.zones.{Zone, ZoneProjectile} -import net.psforever.packet.game.{AIDamage, AvatarGrenadeStateMessage, ChainLashMessage, ChangeAmmoMessage, ChangeFireModeMessage, ChangeFireStateMessage_Start, ChangeFireStateMessage_Stop, HitMessage, InventoryStateMessage, LashMessage, LongRangeProjectileInfoMessage, ObjectAttachMessage, ObjectDeleteMessage, ObjectDetachMessage, ProjectileStateMessage, QuantityUpdateMessage, ReloadMessage, SplashHitMessage, WeaponDelayFireMessage, WeaponDryFireMessage, WeaponFireMessage, WeaponLazeTargetPositionMessage} +import net.psforever.packet.game.{AIDamage, AvatarGrenadeStateMessage, ChainLashMessage, ChangeAmmoMessage, ChangeFireModeMessage, ChangeFireStateMessage_Start, ChangeFireStateMessage_Stop, HitMessage, InventoryStateMessage, LashMessage, LongRangeProjectileInfoMessage, ObjectAttachMessage, ObjectDeleteMessage, ObjectDetachMessage, ProjectileStateMessage, QuantityUpdateMessage, ReloadMessage, SplashHitMessage, UplinkRequest, WeaponDelayFireMessage, WeaponDryFireMessage, WeaponFireMessage, WeaponLazeTargetPositionMessage} import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage} import net.psforever.services.vehicle.{VehicleAction, VehicleServiceMessage} import net.psforever.types.{PlanetSideGUID, Vector3} @@ -36,15 +36,109 @@ import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future import scala.concurrent.duration._ -class WeaponAndProjectileLogic(val ops: WeaponAndProjectileOperations) extends WeaponAndProjectileFunctions { +object WeaponAndProjectileLogic { + def apply(ops: WeaponAndProjectileOperations): WeaponAndProjectileLogic = { + new WeaponAndProjectileLogic(ops, ops.context) + } + + /** + * Does a line segment line intersect with a sphere?
+ * This most likely belongs in `Geometry` or `GeometryForm` or somehow in association with the `\objects\geometry\` package. + * @param start first point of the line segment + * @param end second point of the line segment + * @param center center of the sphere + * @param radius radius of the sphere + * @return list of all points of intersection, if any + * @see `Vector3.DistanceSquared` + * @see `Vector3.MagnitudeSquared` + */ + private def quickLineSphereIntersectionPoints( + start: Vector3, + end: Vector3, + center: Vector3, + radius: Float + ): Iterable[Vector3] = { + /* + Algorithm adapted from code found on https://paulbourke.net/geometry/circlesphere/index.html#linesphere, + because I kept messing up proper substitution of the line formula and the circle formula into the quadratic equation. + */ + val Vector3(cx, cy, cz) = center + val Vector3(sx, sy, sz) = start + val vector = end - start + //speed our way through a quadratic equation + val (a, b) = { + val Vector3(dx, dy, dz) = vector + ( + dx * dx + dy * dy + dz * dz, + 2f * (dx * (sx - cx) + dy * (sy - cy) + dz * (sz - cz)) + ) + } + val c = Vector3.MagnitudeSquared(center) + Vector3.MagnitudeSquared(start) - 2f * (cx * sx + cy * sy + cz * sz) - radius * radius + val result = b * b - 4 * a * c + if (result < 0f) { + //negative, no intersection + Seq() + } else if (result < 0.00001f) { + //zero-ish, one intersection point + Seq(start - vector * (b / (2f * a))) + } else { + //positive, two intersection points + val sqrt = math.sqrt(result).toFloat + val endStart = vector / (2f * a) + Seq(start + endStart * (sqrt - b), start + endStart * (b + sqrt) * -1f) + }.filter(p => Vector3.DistanceSquared(start, p) <= a) + } + /** + * Preparation for explosion damage that utilizes the Scorpion's little buddy sub-projectiles. + * The main difference from "normal" server-side explosion + * is that the owner of the projectile must be clarified explicitly. + * @see `Zone::serverSideDamage` + * @param zone where the explosion is taking place + * (`source` contains the coordinate location) + * @param source a game object that represents the source of the explosion + * @param owner who or what to accredit damage from the explosion to; + * clarifies a normal `SourceEntry(source)` accreditation + */ + private def detonateLittleBuddy( + zone: Zone, + source: PlanetSideGameObject with FactionAffinity with Vitality, + proxy: Projectile, + owner: SourceEntry + )(): Unit = { + Zone.serverSideDamage(zone, source, littleBuddyExplosionDamage(owner, proxy.id, source.Position)) + } + + /** + * Preparation for explosion damage that utilizes the Scorpion's little buddy sub-projectiles. + * The main difference from "normal" server-side explosion + * is that the owner of the projectile must be clarified explicitly. + * The sub-projectiles will be the product of a normal projectile rather than a standard game object + * so a custom `source` entity must wrap around it and fulfill the requirements of the field. + * @see `Zone::explosionDamage` + * @param owner who or what to accredit damage from the explosion to + * @param explosionPosition where the explosion will be positioned in the game world + * @param source a game object that represents the source of the explosion + * @param target a game object that is affected by the explosion + * @return a `DamageInteraction` object + */ + private def littleBuddyExplosionDamage( + owner: SourceEntry, + projectileId: Long, + explosionPosition: Vector3 + ) + ( + source: PlanetSideGameObject with FactionAffinity with Vitality, + target: PlanetSideGameObject with FactionAffinity with Vitality + ): DamageInteraction = { + DamageInteraction(SourceEntry(target), OicwLilBuddyReason(owner, projectileId, target.DamageModel), explosionPosition) + } +} + +class WeaponAndProjectileLogic(val ops: WeaponAndProjectileOperations, implicit val context: ActorContext) extends WeaponAndProjectileFunctions { def sessionLogic: SessionData = ops.sessionLogic - implicit val context: ActorContext = ops.context - private val avatarActor: typed.ActorRef[AvatarActor.Command] = ops.avatarActor - private val chatActor: typed.ActorRef[ChatActor.Command] = ops.chatActor - /* packets */ def handleWeaponFire(pkt: WeaponFireMessage): Unit = { @@ -107,6 +201,10 @@ class WeaponAndProjectileLogic(val ops: WeaponAndProjectileOperations) extends W log.info(s"${player.Name} is lazing a position$purpose") } + def handleUplinkRequest(packet: UplinkRequest): Unit = { + sessionLogic.administrativeKick(player) + } + def handleAvatarGrenadeState(pkt: AvatarGrenadeStateMessage): Unit = { val AvatarGrenadeStateMessage(_, state) = pkt //TODO I thought I had this working? @@ -1242,97 +1340,3 @@ class WeaponAndProjectileLogic(val ops: WeaponAndProjectileOperations) extends W } } } - -object WeaponAndProjectileLogic { - /** - * Does a line segment line intersect with a sphere?
- * This most likely belongs in `Geometry` or `GeometryForm` or somehow in association with the `\objects\geometry\` package. - * @param start first point of the line segment - * @param end second point of the line segment - * @param center center of the sphere - * @param radius radius of the sphere - * @return list of all points of intersection, if any - * @see `Vector3.DistanceSquared` - * @see `Vector3.MagnitudeSquared` - */ - private def quickLineSphereIntersectionPoints( - start: Vector3, - end: Vector3, - center: Vector3, - radius: Float - ): Iterable[Vector3] = { - /* - Algorithm adapted from code found on https://paulbourke.net/geometry/circlesphere/index.html#linesphere, - because I kept messing up proper substitution of the line formula and the circle formula into the quadratic equation. - */ - val Vector3(cx, cy, cz) = center - val Vector3(sx, sy, sz) = start - val vector = end - start - //speed our way through a quadratic equation - val (a, b) = { - val Vector3(dx, dy, dz) = vector - ( - dx * dx + dy * dy + dz * dz, - 2f * (dx * (sx - cx) + dy * (sy - cy) + dz * (sz - cz)) - ) - } - val c = Vector3.MagnitudeSquared(center) + Vector3.MagnitudeSquared(start) - 2f * (cx * sx + cy * sy + cz * sz) - radius * radius - val result = b * b - 4 * a * c - if (result < 0f) { - //negative, no intersection - Seq() - } else if (result < 0.00001f) { - //zero-ish, one intersection point - Seq(start - vector * (b / (2f * a))) - } else { - //positive, two intersection points - val sqrt = math.sqrt(result).toFloat - val endStart = vector / (2f * a) - Seq(start + endStart * (sqrt - b), start + endStart * (b + sqrt) * -1f) - }.filter(p => Vector3.DistanceSquared(start, p) <= a) - } - /** - * Preparation for explosion damage that utilizes the Scorpion's little buddy sub-projectiles. - * The main difference from "normal" server-side explosion - * is that the owner of the projectile must be clarified explicitly. - * @see `Zone::serverSideDamage` - * @param zone where the explosion is taking place - * (`source` contains the coordinate location) - * @param source a game object that represents the source of the explosion - * @param owner who or what to accredit damage from the explosion to; - * clarifies a normal `SourceEntry(source)` accreditation - */ - private def detonateLittleBuddy( - zone: Zone, - source: PlanetSideGameObject with FactionAffinity with Vitality, - proxy: Projectile, - owner: SourceEntry - )(): Unit = { - Zone.serverSideDamage(zone, source, littleBuddyExplosionDamage(owner, proxy.id, source.Position)) - } - - /** - * Preparation for explosion damage that utilizes the Scorpion's little buddy sub-projectiles. - * The main difference from "normal" server-side explosion - * is that the owner of the projectile must be clarified explicitly. - * The sub-projectiles will be the product of a normal projectile rather than a standard game object - * so a custom `source` entity must wrap around it and fulfill the requirements of the field. - * @see `Zone::explosionDamage` - * @param owner who or what to accredit damage from the explosion to - * @param explosionPosition where the explosion will be positioned in the game world - * @param source a game object that represents the source of the explosion - * @param target a game object that is affected by the explosion - * @return a `DamageInteraction` object - */ - private def littleBuddyExplosionDamage( - owner: SourceEntry, - projectileId: Long, - explosionPosition: Vector3 - ) - ( - source: PlanetSideGameObject with FactionAffinity with Vitality, - target: PlanetSideGameObject with FactionAffinity with Vitality - ): DamageInteraction = { - DamageInteraction(SourceEntry(target), OicwLilBuddyReason(owner, projectileId, target.DamageModel), explosionPosition) - } -} diff --git a/src/main/scala/net/psforever/actors/session/spectator/AvatarHandlerLogic.scala b/src/main/scala/net/psforever/actors/session/spectator/AvatarHandlerLogic.scala new file mode 100644 index 000000000..d26de69b3 --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/spectator/AvatarHandlerLogic.scala @@ -0,0 +1,573 @@ +// Copyright (c) 2024 PSForever +package net.psforever.actors.session.spectator + +import akka.actor.{ActorContext, typed} +import net.psforever.actors.session.support.AvatarHandlerFunctions + +import scala.concurrent.duration._ +// +import net.psforever.actors.session.AvatarActor +import net.psforever.actors.session.support.{SessionAvatarHandlers, SessionData} +import net.psforever.login.WorldSession.{DropEquipmentFromInventory, DropLeftovers, HoldNewEquipmentUp} +import net.psforever.objects.{GlobalDefinitions, Player, Tool, Vehicle} +import net.psforever.objects.guid.{GUIDTask, TaskWorkflow} +import net.psforever.objects.inventory.InventoryItem +import net.psforever.objects.serverobject.pad.VehicleSpawnPad +import net.psforever.objects.serverobject.terminals.{ProximityUnit, Terminal} +import net.psforever.objects.vital.etc.ExplodingEntityReason +import net.psforever.objects.zones.Zoning +import net.psforever.packet.game.objectcreate.ObjectCreateMessageParent +import net.psforever.packet.game.{ArmorChangedMessage, AvatarDeadStateMessage, ChangeAmmoMessage, ChangeFireModeMessage, ChangeFireStateMessage_Start, ChangeFireStateMessage_Stop, ChatMsg, DeadState, DestroyMessage, DrowningTarget, GenericActionMessage, GenericObjectActionMessage, HitHint, ItemTransactionResultMessage, ObjectCreateDetailedMessage, ObjectCreateMessage, ObjectDeleteMessage, ObjectHeldMessage, OxygenStateMessage, PlanetsideAttributeMessage, PlayerStateMessage, ProjectileStateMessage, ReloadMessage, SetEmpireMessage, UseItemMessage, WeaponDryFireMessage} +import net.psforever.services.avatar.{AvatarAction, AvatarResponse, AvatarServiceMessage} +import net.psforever.services.Service +import net.psforever.types.{ChatMessageType, PlanetSideGUID, TransactionType, Vector3} +import net.psforever.util.Config + +object AvatarHandlerLogic { + def apply(ops: SessionAvatarHandlers): AvatarHandlerLogic = { + new AvatarHandlerLogic(ops, ops.context) + } +} + +class AvatarHandlerLogic(val ops: SessionAvatarHandlers, implicit val context: ActorContext) extends AvatarHandlerFunctions { + def sessionLogic: SessionData = ops.sessionLogic + + private val avatarActor: typed.ActorRef[AvatarActor.Command] = ops.avatarActor + + /** + * na + * @param toChannel na + * @param guid na + * @param reply na + */ + def handle(toChannel: String, guid: PlanetSideGUID, reply: AvatarResponse.Response): Unit = { + val resolvedPlayerGuid = if (player != null && player.HasGUID) { + player.GUID + } else { + Service.defaultPlayerGUID + } + val isNotSameTarget = resolvedPlayerGuid != guid + val isSameTarget = !isNotSameTarget + reply match { + /* special messages */ + case AvatarResponse.TeardownConnection() => + log.trace(s"ending ${player.Name}'s old session by event system request (relog)") + context.stop(context.self) + + /* really common messages (very frequently, every life) */ + case pstate @ AvatarResponse.PlayerState( + pos, + vel, + yaw, + pitch, + yawUpper, + _, + isCrouching, + isJumping, + jumpThrust, + isCloaking, + isNotRendered, + canSeeReallyFar + ) if isNotSameTarget => + val pstateToSave = pstate.copy(timestamp = 0) + val (lastMsg, lastTime, lastPosition, wasVisible, wasShooting) = ops.lastSeenStreamMessage.get(guid.guid) match { + case Some(SessionAvatarHandlers.LastUpstream(Some(msg), visible, shooting, time)) => (Some(msg), time, msg.pos, visible, shooting) + case _ => (None, 0L, Vector3.Zero, false, None) + } + val drawConfig = Config.app.game.playerDraw //m + val maxRange = drawConfig.rangeMax * drawConfig.rangeMax //sq.m + val ourPosition = player.Position //xyz + val currentDistance = Vector3.DistanceSquared(ourPosition, pos) //sq.m + val inDrawableRange = currentDistance <= maxRange + val now = System.currentTimeMillis() //ms + if ( + sessionLogic.zoning.zoningStatus != Zoning.Status.Deconstructing && + !isNotRendered && inDrawableRange + ) { + //conditions where visibility is assured + val durationSince = now - lastTime //ms + lazy val previouslyInDrawableRange = Vector3.DistanceSquared(ourPosition, lastPosition) <= maxRange + lazy val targetDelay = { + val populationOver = math.max( + 0, + sessionLogic.localSector.livePlayerList.size - drawConfig.populationThreshold + ) + val distanceAdjustment = math.pow(populationOver / drawConfig.populationStep * drawConfig.rangeStep, 2) //sq.m + val adjustedDistance = currentDistance + distanceAdjustment //sq.m + drawConfig.ranges.lastIndexWhere { dist => adjustedDistance > dist * dist } match { + case -1 => 1 + case index => drawConfig.delays(index) + } + } //ms + if (!wasVisible || + !previouslyInDrawableRange || + durationSince > drawConfig.delayMax || + (!lastMsg.contains(pstateToSave) && + (canSeeReallyFar || + currentDistance < drawConfig.rangeMin * drawConfig.rangeMin || + sessionLogic.general.canSeeReallyFar || + durationSince > targetDelay + ) + ) + ) { + //must draw + sendResponse( + PlayerStateMessage( + guid, + pos, + vel, + yaw, + pitch, + yawUpper, + timestamp = 0, //is this okay? + isCrouching, + isJumping, + jumpThrust, + isCloaking + ) + ) + ops.lastSeenStreamMessage.put(guid.guid, SessionAvatarHandlers.LastUpstream(Some(pstateToSave), visible=true, wasShooting, now)) + } else { + //is visible, but skip reinforcement + ops.lastSeenStreamMessage.put(guid.guid, SessionAvatarHandlers.LastUpstream(Some(pstateToSave), visible=true, wasShooting, lastTime)) + } + } else { + //conditions where the target is not currently visible + if (wasVisible) { + //the target was JUST PREVIOUSLY visible; one last draw to move target beyond a renderable distance + val lat = (1 + ops.hidingPlayerRandomizer.nextInt(continent.map.scale.height.toInt)).toFloat + sendResponse( + PlayerStateMessage( + guid, + Vector3(1f, lat, 1f), + vel=None, + facingYaw=0f, + facingPitch=0f, + facingYawUpper=0f, + timestamp=0, //is this okay? + is_cloaked = isCloaking + ) + ) + ops.lastSeenStreamMessage.put(guid.guid, SessionAvatarHandlers.LastUpstream(Some(pstateToSave), visible=false, wasShooting, now)) + } else { + //skip drawing altogether + ops.lastSeenStreamMessage.put(guid.guid, SessionAvatarHandlers.LastUpstream(Some(pstateToSave), visible=false, wasShooting, lastTime)) + } + } + + case AvatarResponse.ObjectHeld(slot, _) + if isSameTarget && player.VisibleSlots.contains(slot) => + sendResponse(ObjectHeldMessage(guid, slot, unk1=true)) + //Stop using proximity terminals if player unholsters a weapon + continent.GUID(sessionLogic.terminals.usingMedicalTerminal).collect { + case term: Terminal with ProximityUnit => sessionLogic.terminals.StopUsingProximityUnit(term) + } + if (sessionLogic.zoning.zoningStatus == Zoning.Status.Deconstructing) { + sessionLogic.zoning.spawn.stopDeconstructing() + } + + case AvatarResponse.ObjectHeld(slot, _) + if isSameTarget && slot > -1 => + sendResponse(ObjectHeldMessage(guid, slot, unk1=true)) + + case AvatarResponse.ObjectHeld(_, _) + if isSameTarget => () + + case AvatarResponse.ObjectHeld(_, previousSlot) => + sendResponse(ObjectHeldMessage(guid, previousSlot, unk1=false)) + + case AvatarResponse.ChangeFireState_Start(weaponGuid) + if isNotSameTarget && ops.lastSeenStreamMessage.get(guid.guid).exists { _.visible } => + sendResponse(ChangeFireStateMessage_Start(weaponGuid)) + val entry = ops.lastSeenStreamMessage(guid.guid) + ops.lastSeenStreamMessage.put(guid.guid, entry.copy(shooting = Some(weaponGuid))) + + case AvatarResponse.ChangeFireState_Start(weaponGuid) + if isNotSameTarget => + sendResponse(ChangeFireStateMessage_Start(weaponGuid)) + + case AvatarResponse.ChangeFireState_Stop(weaponGuid) + if isNotSameTarget && ops.lastSeenStreamMessage.get(guid.guid).exists { msg => msg.visible || msg.shooting.nonEmpty } => + sendResponse(ChangeFireStateMessage_Stop(weaponGuid)) + val entry = ops.lastSeenStreamMessage(guid.guid) + ops.lastSeenStreamMessage.put(guid.guid, entry.copy(shooting = None)) + + case AvatarResponse.ChangeFireState_Stop(weaponGuid) + if isNotSameTarget => + sendResponse(ChangeFireStateMessage_Stop(weaponGuid)) + + case AvatarResponse.LoadPlayer(pkt) if isNotSameTarget => + sendResponse(pkt) + + case AvatarResponse.EquipmentInHand(pkt) if isNotSameTarget => + sendResponse(pkt) + + case AvatarResponse.PlanetsideAttribute(attributeType, attributeValue) if isNotSameTarget => + sendResponse(PlanetsideAttributeMessage(guid, attributeType, attributeValue)) + + case AvatarResponse.PlanetsideAttributeToAll(attributeType, attributeValue) => + sendResponse(PlanetsideAttributeMessage(guid, attributeType, attributeValue)) + + case AvatarResponse.PlanetsideAttributeSelf(attributeType, attributeValue) if isSameTarget => + sendResponse(PlanetsideAttributeMessage(guid, attributeType, attributeValue)) + + case AvatarResponse.GenericObjectAction(objectGuid, actionCode) if isNotSameTarget => + sendResponse(GenericObjectActionMessage(objectGuid, actionCode)) + + case AvatarResponse.HitHint(sourceGuid) if player.isAlive => + sendResponse(HitHint(sourceGuid, guid)) + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_dmg") + + case AvatarResponse.Destroy(victim, killer, weapon, pos) => + // guid = victim // killer = killer + sendResponse(DestroyMessage(victim, killer, weapon, pos)) + + case AvatarResponse.DestroyDisplay(killer, victim, method, unk) => + sendResponse(ops.destroyDisplayMessage(killer, victim, method, unk)) + + case AvatarResponse.TerminalOrderResult(terminalGuid, action, result) + if result && (action == TransactionType.Buy || action == TransactionType.Loadout) => + sendResponse(ItemTransactionResultMessage(terminalGuid, action, result)) + sessionLogic.terminals.lastTerminalOrderFulfillment = true + AvatarActor.savePlayerData(player) + sessionLogic.general.renewCharSavedTimer( + Config.app.game.savedMsg.interruptedByAction.fixed, + Config.app.game.savedMsg.interruptedByAction.variable + ) + + case AvatarResponse.TerminalOrderResult(terminalGuid, action, result) => + sendResponse(ItemTransactionResultMessage(terminalGuid, action, result)) + sessionLogic.terminals.lastTerminalOrderFulfillment = true + + case AvatarResponse.ChangeExosuit( + target, + armor, + exosuit, + subtype, + _, + maxhand, + oldHolsters, + holsters, + oldInventory, + inventory, + drop, + delete + ) if resolvedPlayerGuid == target => + sendResponse(ArmorChangedMessage(target, exosuit, subtype)) + sendResponse(PlanetsideAttributeMessage(target, attribute_type=4, armor)) + //happening to this player + //cleanup + sendResponse(ObjectHeldMessage(target, Player.HandsDownSlot, unk1=false)) + (oldHolsters ++ oldInventory ++ delete).foreach { + case (_, dguid) => sendResponse(ObjectDeleteMessage(dguid, unk1=0)) + } + //functionally delete + delete.foreach { case (obj, _) => TaskWorkflow.execute(GUIDTask.unregisterEquipment(continent.GUID, obj)) } + //redraw + if (maxhand) { + TaskWorkflow.execute(HoldNewEquipmentUp(player)( + Tool(GlobalDefinitions.MAXArms(subtype, player.Faction)), + 0 + )) + } + //draw free hand + player.FreeHand.Equipment.foreach { obj => + val definition = obj.Definition + sendResponse( + ObjectCreateDetailedMessage( + definition.ObjectId, + obj.GUID, + ObjectCreateMessageParent(target, Player.FreeHandSlot), + definition.Packet.DetailedConstructorData(obj).get + ) + ) + } + //draw holsters and inventory + (holsters ++ inventory).foreach { + case InventoryItem(obj, index) => + val definition = obj.Definition + sendResponse( + ObjectCreateDetailedMessage( + definition.ObjectId, + obj.GUID, + ObjectCreateMessageParent(target, index), + definition.Packet.DetailedConstructorData(obj).get + ) + ) + } + DropLeftovers(player)(drop) + + case AvatarResponse.ChangeExosuit(target, armor, exosuit, subtype, slot, _, oldHolsters, holsters, _, _, _, delete) => + sendResponse(ArmorChangedMessage(target, exosuit, subtype)) + sendResponse(PlanetsideAttributeMessage(target, attribute_type=4, armor)) + //happening to some other player + sendResponse(ObjectHeldMessage(target, slot, unk1 = false)) + //cleanup + (oldHolsters ++ delete).foreach { case (_, guid) => sendResponse(ObjectDeleteMessage(guid, unk1=0)) } + //draw holsters + holsters.foreach { + case InventoryItem(obj, index) => + val definition = obj.Definition + sendResponse( + ObjectCreateMessage( + definition.ObjectId, + obj.GUID, + ObjectCreateMessageParent(target, index), + definition.Packet.ConstructorData(obj).get + ) + ) + } + + case AvatarResponse.ChangeLoadout( + target, + armor, + exosuit, + subtype, + _, + maxhand, + oldHolsters, + holsters, + oldInventory, + inventory, + drops + ) if resolvedPlayerGuid == target => + sendResponse(ArmorChangedMessage(target, exosuit, subtype)) + sendResponse(PlanetsideAttributeMessage(target, attribute_type = 4, armor)) + //happening to this player + sendResponse(ObjectHeldMessage(target, Player.HandsDownSlot, unk1=true)) + //cleanup + (oldHolsters ++ oldInventory).foreach { + case (obj, objGuid) => + sendResponse(ObjectDeleteMessage(objGuid, unk1=0)) + TaskWorkflow.execute(GUIDTask.unregisterEquipment(continent.GUID, obj)) + } + drops.foreach(item => sendResponse(ObjectDeleteMessage(item.obj.GUID, unk1=0))) + //redraw + if (maxhand) { + TaskWorkflow.execute(HoldNewEquipmentUp(player)( + Tool(GlobalDefinitions.MAXArms(subtype, player.Faction)), + slot = 0 + )) + } + sessionLogic.general.applyPurchaseTimersBeforePackingLoadout(player, player, holsters ++ inventory) + DropLeftovers(player)(drops) + + case AvatarResponse.ChangeLoadout(target, armor, exosuit, subtype, slot, _, oldHolsters, _, _, _, _) => + //redraw handled by callbacks + sendResponse(ArmorChangedMessage(target, exosuit, subtype)) + sendResponse(PlanetsideAttributeMessage(target, attribute_type=4, armor)) + //happening to some other player + sendResponse(ObjectHeldMessage(target, slot, unk1=false)) + //cleanup + oldHolsters.foreach { case (_, guid) => sendResponse(ObjectDeleteMessage(guid, unk1=0)) } + + case AvatarResponse.UseKit(kguid, kObjId) => + sendResponse( + UseItemMessage( + resolvedPlayerGuid, + kguid, + resolvedPlayerGuid, + unk2 = 4294967295L, + unk3 = false, + unk4 = Vector3.Zero, + unk5 = Vector3.Zero, + unk6 = 126, + unk7 = 0, //sequence time? + unk8 = 137, + kObjId + ) + ) + sendResponse(ObjectDeleteMessage(kguid, unk1=0)) + + case AvatarResponse.KitNotUsed(_, "") => + sessionLogic.general.kitToBeUsed = None + + case AvatarResponse.KitNotUsed(_, msg) => + sessionLogic.general.kitToBeUsed = None + sendResponse(ChatMsg(ChatMessageType.UNK_225, msg)) + + case AvatarResponse.UpdateKillsDeathsAssists(_, kda) => + avatarActor ! AvatarActor.UpdateKillsDeathsAssists(kda) + + case AvatarResponse.AwardBep(charId, bep, expType) => + //if the target player, always award (some) BEP + if (charId == player.CharId) { + avatarActor ! AvatarActor.AwardBep(bep, expType) + } + + case AvatarResponse.AwardCep(charId, cep) => + //if the target player, always award (some) CEP + if (charId == player.CharId) { + avatarActor ! AvatarActor.AwardCep(cep) + } + + case AvatarResponse.FacilityCaptureRewards(buildingId, zoneNumber, cep) => + ops.facilityCaptureRewards(buildingId, zoneNumber, cep) + + case AvatarResponse.SendResponse(msg) => + sendResponse(msg) + + case AvatarResponse.SendResponseTargeted(targetGuid, msg) if resolvedPlayerGuid == targetGuid => + sendResponse(msg) + + /* common messages (maybe once every respawn) */ + case AvatarResponse.Reload(itemGuid) + if isNotSameTarget && ops.lastSeenStreamMessage.get(guid.guid).exists { _.visible } => + sendResponse(ReloadMessage(itemGuid, ammo_clip=1, unk1=0)) + + case AvatarResponse.Killed(mount) => + //log and chat messages + val cause = player.LastDamage.flatMap { damage => + val interaction = damage.interaction + val reason = interaction.cause + val adversarial = interaction.adversarial.map { _.attacker } + reason match { + case r: ExplodingEntityReason if r.entity.isInstanceOf[VehicleSpawnPad] => + //also, @SVCP_Killed_TooCloseToPadOnCreate^n~ or "... within n meters of pad ..." + sendResponse(ChatMsg(ChatMessageType.UNK_227, "@SVCP_Killed_OnPadOnCreate")) + case _ => () + } + adversarial.map {_.Name }.orElse { Some(s"a ${reason.getClass.getSimpleName}") } + }.getOrElse { s"an unfortunate circumstance (probably ${player.Sex.pronounObject} own fault)" } + log.info(s"${player.Name} has died, killed by $cause") + if (sessionLogic.shooting.shotsWhileDead > 0) { + log.warn( + s"SHOTS_WHILE_DEAD: client of ${avatar.name} fired ${sessionLogic.shooting.shotsWhileDead} rounds while character was dead on server" + ) + sessionLogic.shooting.shotsWhileDead = 0 + } + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason(msg = "cancel") + sessionLogic.general.renewCharSavedTimer(fixedLen = 1800L, varLen = 0L) + + //player state changes + AvatarActor.updateToolDischargeFor(avatar) + player.FreeHand.Equipment.foreach { item => + DropEquipmentFromInventory(player)(item) + } + sessionLogic.general.dropSpecialSlotItem() + sessionLogic.general.toggleMaxSpecialState(enable = false) + sessionLogic.keepAliveFunc = sessionLogic.zoning.NormalKeepAlive + sessionLogic.zoning.zoningStatus = Zoning.Status.None + sessionLogic.zoning.spawn.deadState = DeadState.Dead + continent.GUID(mount).collect { case obj: Vehicle => + sessionLogic.vehicles.ConditionalDriverVehicleControl(obj) + sessionLogic.general.unaccessContainer(obj) + } + sessionLogic.actionsToCancel() + sessionLogic.terminals.CancelAllProximityUnits() + AvatarActor.savePlayerLocation(player) + sessionLogic.zoning.spawn.shiftPosition = Some(player.Position) + + //respawn + sessionLogic.zoning.spawn.reviveTimer.cancel() + if (player.death_by == 0) { + sessionLogic.zoning.spawn.randomRespawn(300.seconds) + } else { + sessionLogic.zoning.spawn.HandleReleaseAvatar(player, continent) + } + + case AvatarResponse.Release(tplayer) if isNotSameTarget => + sessionLogic.zoning.spawn.DepictPlayerAsCorpse(tplayer) + + case AvatarResponse.Revive(revivalTargetGuid) if resolvedPlayerGuid == revivalTargetGuid => + log.info(s"No time for rest, ${player.Name}. Back on your feet!") + sessionLogic.zoning.spawn.reviveTimer.cancel() + sessionLogic.zoning.spawn.deadState = DeadState.Alive + player.Revive + val health = player.Health + sendResponse(PlanetsideAttributeMessage(revivalTargetGuid, attribute_type=0, health)) + sendResponse(AvatarDeadStateMessage(DeadState.Alive, timer_max=0, timer=0, player.Position, player.Faction, unk5=true)) + continent.AvatarEvents ! AvatarServiceMessage( + continent.id, + AvatarAction.PlanetsideAttributeToAll(revivalTargetGuid, attribute_type=0, health) + ) + + /* uncommon messages (utility, or once in a while) */ + case AvatarResponse.ChangeAmmo(weapon_guid, weapon_slot, previous_guid, ammo_id, ammo_guid, ammo_data) + if isNotSameTarget && ops.lastSeenStreamMessage.get(guid.guid).exists { _.visible } => + ops.changeAmmoProcedures(weapon_guid, previous_guid, ammo_id, ammo_guid, weapon_slot, ammo_data) + sendResponse(ChangeAmmoMessage(weapon_guid, 1)) + + case AvatarResponse.ChangeAmmo(weapon_guid, weapon_slot, previous_guid, ammo_id, ammo_guid, ammo_data) + if isNotSameTarget => + ops.changeAmmoProcedures(weapon_guid, previous_guid, ammo_id, ammo_guid, weapon_slot, ammo_data) + + case AvatarResponse.ChangeFireMode(itemGuid, mode) if isNotSameTarget => + sendResponse(ChangeFireModeMessage(itemGuid, mode)) + + case AvatarResponse.ConcealPlayer() => + sendResponse(GenericObjectActionMessage(guid, code=9)) + + case AvatarResponse.EnvironmentalDamage(_, _, _) => + //TODO damage marker? + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_dmg") + + case AvatarResponse.DropItem(pkt) if isNotSameTarget => + sendResponse(pkt) + + case AvatarResponse.ObjectDelete(itemGuid, unk) if isNotSameTarget => + sendResponse(ObjectDeleteMessage(itemGuid, unk)) + + /* rare messages */ + case AvatarResponse.SetEmpire(objectGuid, faction) if isNotSameTarget => + sendResponse(SetEmpireMessage(objectGuid, faction)) + + case AvatarResponse.DropSpecialItem() => + sessionLogic.general.dropSpecialSlotItem() + + case AvatarResponse.OxygenState(player, vehicle) => + sendResponse(OxygenStateMessage( + DrowningTarget(player.guid, player.progress, player.state), + vehicle.flatMap { vinfo => Some(DrowningTarget(vinfo.guid, vinfo.progress, vinfo.state)) } + )) + + case AvatarResponse.LoadProjectile(pkt) if isNotSameTarget => + sendResponse(pkt) + + case AvatarResponse.ProjectileState(projectileGuid, shotPos, shotVel, shotOrient, seq, end, targetGuid) if isNotSameTarget => + sendResponse(ProjectileStateMessage(projectileGuid, shotPos, shotVel, shotOrient, seq, end, targetGuid)) + + case AvatarResponse.ProjectileExplodes(projectileGuid, projectile) => + sendResponse( + ProjectileStateMessage( + projectileGuid, + projectile.Position, + shot_vel = Vector3.Zero, + projectile.Orientation, + sequence_num=0, + end=true, + hit_target_guid=PlanetSideGUID(0) + ) + ) + sendResponse(ObjectDeleteMessage(projectileGuid, unk1=2)) + + case AvatarResponse.ProjectileAutoLockAwareness(mode) => + sendResponse(GenericActionMessage(mode)) + + case AvatarResponse.PutDownFDU(target) if isNotSameTarget => + sendResponse(GenericObjectActionMessage(target, code=53)) + + case AvatarResponse.StowEquipment(target, slot, item) if isNotSameTarget => + val definition = item.Definition + sendResponse( + ObjectCreateDetailedMessage( + definition.ObjectId, + item.GUID, + ObjectCreateMessageParent(target, slot), + definition.Packet.DetailedConstructorData(item).get + ) + ) + + case AvatarResponse.WeaponDryFire(weaponGuid) + if isNotSameTarget && ops.lastSeenStreamMessage.get(guid.guid).exists { _.visible } => + continent.GUID(weaponGuid).collect { + case tool: Tool if tool.Magazine == 0 => + // check that the magazine is still empty before sending WeaponDryFireMessage + // if it has been reloaded since then, other clients will not see it firing + sendResponse(WeaponDryFireMessage(weaponGuid)) + } + + case _ => () + } + } +} diff --git a/src/main/scala/net/psforever/actors/session/spectator/GalaxyHandlerLogic.scala b/src/main/scala/net/psforever/actors/session/spectator/GalaxyHandlerLogic.scala new file mode 100644 index 000000000..7ecff65b9 --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/spectator/GalaxyHandlerLogic.scala @@ -0,0 +1,85 @@ +// Copyright (c) 2024 PSForever +package net.psforever.actors.session.spectator + +import akka.actor.{ActorContext, ActorRef, typed} +import net.psforever.actors.session.AvatarActor +import net.psforever.actors.session.support.{GalaxyHandlerFunctions, SessionGalaxyHandlers, SessionData} +import net.psforever.packet.game.{BroadcastWarpgateUpdateMessage, FriendsResponse, HotSpotUpdateMessage, ZoneInfoMessage, ZonePopulationUpdateMessage, HotSpotInfo => PacketHotSpotInfo} +import net.psforever.services.galaxy.{GalaxyAction, GalaxyResponse, GalaxyServiceMessage} +import net.psforever.types.{MemberAction, PlanetSideEmpire} + +object GalaxyHandlerLogic { + def apply(ops: SessionGalaxyHandlers): GalaxyHandlerLogic = { + new GalaxyHandlerLogic(ops, ops.context) + } +} + +class GalaxyHandlerLogic(val ops: SessionGalaxyHandlers, implicit val context: ActorContext) extends GalaxyHandlerFunctions { + def sessionLogic: SessionData = ops.sessionLogic + + private val avatarActor: typed.ActorRef[AvatarActor.Command] = ops.avatarActor + + private val galaxyService: ActorRef = ops.galaxyService + + /* packets */ + + def handleUpdateIgnoredPlayers(pkt: FriendsResponse): Unit = { + sendResponse(pkt) + pkt.friends.foreach { f => + galaxyService ! GalaxyServiceMessage(GalaxyAction.LogStatusChange(f.name)) + } + } + + /* response handlers */ + + def handle(reply: GalaxyResponse.Response): Unit = { + reply match { + case GalaxyResponse.HotSpotUpdate(zone_index, priority, hot_spot_info) => + sendResponse( + HotSpotUpdateMessage( + zone_index, + priority, + hot_spot_info.map { spot => PacketHotSpotInfo(spot.DisplayLocation.x, spot.DisplayLocation.y, 40) } + ) + ) + + case GalaxyResponse.MapUpdate(msg) => + sendResponse(msg) + + case GalaxyResponse.UpdateBroadcastPrivileges(zoneId, gateMapId, fromFactions, toFactions) => + val faction = player.Faction + val from = fromFactions.contains(faction) + val to = toFactions.contains(faction) + if (from && !to) { + sendResponse(BroadcastWarpgateUpdateMessage(zoneId, gateMapId, PlanetSideEmpire.NEUTRAL)) + } else if (!from && to) { + sendResponse(BroadcastWarpgateUpdateMessage(zoneId, gateMapId, faction)) + } + + case GalaxyResponse.FlagMapUpdate(msg) => + sendResponse(msg) + + case GalaxyResponse.TransferPassenger(temp_channel, vehicle, _, manifest) => + sessionLogic.zoning.handleTransferPassenger(temp_channel, vehicle, manifest) + + case GalaxyResponse.LockedZoneUpdate(zone, time) => + sendResponse(ZoneInfoMessage(zone.Number, empire_status=false, lock_time=time)) + + case GalaxyResponse.UnlockedZoneUpdate(zone) => ; + sendResponse(ZoneInfoMessage(zone.Number, empire_status=true, lock_time=0L)) + val popBO = 0 + val popTR = zone.Players.count(_.faction == PlanetSideEmpire.TR) + val popNC = zone.Players.count(_.faction == PlanetSideEmpire.NC) + val popVS = zone.Players.count(_.faction == PlanetSideEmpire.VS) + sendResponse(ZonePopulationUpdateMessage(zone.Number, 414, 138, popTR, 138, popNC, 138, popVS, 138, popBO)) + + case GalaxyResponse.LogStatusChange(name) if avatar.people.friend.exists(_.name.equals(name)) => + avatarActor ! AvatarActor.MemberListRequest(MemberAction.UpdateFriend, name) + + case GalaxyResponse.SendResponse(msg) => + sendResponse(msg) + + case _ => () + } + } +} diff --git a/src/main/scala/net/psforever/actors/session/spectator/GeneralLogic.scala b/src/main/scala/net/psforever/actors/session/spectator/GeneralLogic.scala new file mode 100644 index 000000000..f40f3e9a1 --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/spectator/GeneralLogic.scala @@ -0,0 +1,640 @@ +// Copyright (c) 2024 PSForever +package net.psforever.actors.session.spectator + +import akka.actor.{ActorContext, typed} +import net.psforever.actors.session.{AvatarActor, ChatActor} +import net.psforever.actors.session.support.{GeneralFunctions, GeneralOperations, SessionData} +import net.psforever.login.WorldSession.RemoveOldEquipmentFromInventory +import net.psforever.objects.{Account, BoomerDeployable, BoomerTrigger, GlobalDefinitions, LivePlayerList, PlanetSideGameObject, Player, TelepadDeployable, Tool, Vehicle} +import net.psforever.objects.avatar.Avatar +import net.psforever.objects.ballistics.Projectile +import net.psforever.objects.ce.{Deployable, TelepadLike} +import net.psforever.objects.definition.{BasicDefinition, KitDefinition, SpecialExoSuitDefinition} +import net.psforever.objects.equipment.Equipment +import net.psforever.objects.guid.{GUIDTask, TaskWorkflow} +import net.psforever.objects.inventory.Container +import net.psforever.objects.serverobject.{CommonMessages, PlanetSideServerObject} +import net.psforever.objects.serverobject.doors.Door +import net.psforever.objects.sourcing.{PlayerSource, VehicleSource} +import net.psforever.objects.vehicles.{Utility, UtilityType} +import net.psforever.objects.vehicles.Utility.InternalTelepad +import net.psforever.objects.vital.{VehicleDismountActivity, VehicleMountActivity} +import net.psforever.objects.zones.{Zone, ZoneProjectile} +import net.psforever.packet.PlanetSideGamePacket +import net.psforever.packet.game.{ActionCancelMessage, AvatarFirstTimeEventMessage, AvatarImplantMessage, AvatarJumpMessage, BattleplanMessage, BindPlayerMessage, BugReportMessage, ChangeFireModeMessage, ChangeShortcutBankMessage, CharacterCreateRequestMessage, CharacterRequestMessage, ChatMsg, ConnectToWorldRequestMessage, CreateShortcutMessage, DeployObjectMessage, DisplayedAwardMessage, DropItemMessage, EmoteMsg, FacilityBenefitShieldChargeRequestMessage, FriendsRequest, GenericAction, GenericActionMessage, GenericCollisionMsg, GenericObjectActionAtPositionMessage, GenericObjectActionMessage, GenericObjectStateMsg, HitHint, ImplantAction, InvalidTerrainMessage, LootItemMessage, MoveItemMessage, ObjectDeleteMessage, ObjectDetectedMessage, ObjectHeldMessage, PickupItemMessage, PlanetsideAttributeMessage, PlayerStateMessageUpstream, PlayerStateShiftMessage, RequestDestroyMessage, SetChatFilterMessage, ShiftState, TargetInfo, TargetingImplantRequest, TargetingInfoMessage, TradeMessage, UnuseItemMessage, UseItemMessage, VoiceHostInfo, VoiceHostKill, VoiceHostRequest, ZipLineMessage} +import net.psforever.services.RemoverActor +import net.psforever.services.account.AccountPersistenceService +import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage} +import net.psforever.services.local.{LocalAction, LocalServiceMessage} +import net.psforever.types.{ChatMessageType, DriveState, ExoSuitType, PlanetSideGUID, Vector3} +import net.psforever.util.Config + +object GeneralLogic { + def apply(ops: GeneralOperations): GeneralLogic = { + new GeneralLogic(ops, ops.context) + } +} + +class GeneralLogic(val ops: GeneralOperations, implicit val context: ActorContext) extends GeneralFunctions { + def sessionLogic: SessionData = ops.sessionLogic + + private val avatarActor: typed.ActorRef[AvatarActor.Command] = ops.avatarActor + + private val chatActor: typed.ActorRef[ChatActor.Command] = ops.chatActor + + private var customImplants = SpectatorModeLogic.SpectatorImplants.map(_.get) + + def handleConnectToWorldRequest(pkt: ConnectToWorldRequestMessage): Unit = { /* intentionally blank */ } + + def handleCharacterCreateRequest(pkt: CharacterCreateRequestMessage): Unit = { /* intentionally blank */ } + + def handleCharacterRequest(pkt: CharacterRequestMessage): Unit = { /* intentionally blank */ } + + def handlePlayerStateUpstream(pkt: PlayerStateMessageUpstream): Unit = { + val PlayerStateMessageUpstream( + avatarGuid, + pos, + vel, + yaw, + pitch, + yawUpper, + _/*seqTime*/, + _, + isCrouching, + isJumping, + _/*jumpThrust*/, + isCloaking, + _, + _ + )= pkt + sessionLogic.persist() + sessionLogic.turnCounterFunc(avatarGuid) + ops.fallHeightTracker(pos.z) + // if (isCrouching && !player.Crouching) { + // //dev stuff goes here + // } + player.Position = pos + player.Velocity = vel + player.Orientation = Vector3(player.Orientation.x, pitch, yaw) + player.FacingYawUpper = yawUpper + player.Crouching = isCrouching + player.Jumping = isJumping + player.Cloaked = player.ExoSuit == ExoSuitType.Infiltration && isCloaking + if (player.death_by == -1) { + sessionLogic.kickedByAdministration() + } + } + + def handleChat(pkt: ChatMsg): Unit = { + chatActor ! ChatActor.Message(pkt) + } + + def handleChatFilter(pkt: SetChatFilterMessage): Unit = { /* intentionally blank */ } + + def handleVoiceHostRequest(pkt: VoiceHostRequest): Unit = { + log.debug(s"$pkt") + sendResponse(VoiceHostKill()) + sendResponse( + ChatMsg(ChatMessageType.CMT_OPEN, wideContents=false, "", "Try our Discord at https://discord.gg/0nRe5TNbTYoUruA4", None) + ) + } + + def handleVoiceHostInfo(pkt: VoiceHostInfo): Unit = { + log.debug(s"$pkt") + sendResponse(VoiceHostKill()) + sendResponse( + ChatMsg(ChatMessageType.CMT_OPEN, wideContents=false, "", "Try our Discord at https://discord.gg/0nRe5TNbTYoUruA4", None) + ) + } + + def handleEmote(pkt: EmoteMsg): Unit = { + val EmoteMsg(avatarGuid, emote) = pkt + sendResponse(EmoteMsg(avatarGuid, emote)) + } + + def handleDropItem(pkt: DropItemMessage): Unit = { /* intentionally blank */ } + + def handlePickupItem(pkt: PickupItemMessage): Unit = { /* intentionally blank */ } + + def handleObjectHeld(pkt: ObjectHeldMessage): Unit = { + val ObjectHeldMessage(_, heldHolsters, _) = pkt + if (heldHolsters != Player.HandsDownSlot && heldHolsters != 4) { + sendResponse(ObjectHeldMessage(player.GUID, Player.HandsDownSlot, unk1=true)) + } + } + + def handleAvatarJump(pkt: AvatarJumpMessage): Unit = { /* intentionally blank */ } + + def handleZipLine(pkt: ZipLineMessage): Unit = { + val ZipLineMessage(playerGuid, forwards, action, pathId, pos) = pkt + continent.zipLinePaths.find(x => x.PathId == pathId) match { + case Some(path) if path.IsTeleporter => + val endPoint = path.ZipLinePoints.last + sendResponse(ZipLineMessage(PlanetSideGUID(0), forwards, 0, pathId, pos)) + //todo: send to zone to show teleport animation to all clients + sendResponse(PlayerStateShiftMessage(ShiftState(0, endPoint, (player.Orientation.z + player.FacingYawUpper) % 360f, None))) + case Some(_) => + action match { + case 0 => + //travel along the zipline in the direction specified + sendResponse(ZipLineMessage(playerGuid, forwards, action, pathId, pos)) + case 1 => + //disembark from zipline at destination! + sendResponse(ZipLineMessage(playerGuid, forwards, action, 0, pos)) + case 2 => + //get off by force + sendResponse(ZipLineMessage(playerGuid, forwards, action, 0, pos)) + case _ => + log.warn( + s"${player.Name} tried to do something with a zipline but can't handle it. forwards: $forwards action: $action pathId: $pathId zone: ${continent.Number} / ${continent.id}" + ) + } + case _ => + log.warn(s"${player.Name} couldn't find a zipline path $pathId in zone ${continent.id}") + } + } + + def handleRequestDestroy(pkt: RequestDestroyMessage): Unit = { + val RequestDestroyMessage(objectGuid) = pkt + //make sure this is the correct response for all cases + sessionLogic.validObject(objectGuid, decorator = "RequestDestroy") match { + case Some(obj: Projectile) => + if (!obj.isResolved) { + obj.Miss() + } + continent.Projectile ! ZoneProjectile.Remove(objectGuid) + + case Some(obj: BoomerTrigger) => + if (findEquipmentToDelete(objectGuid, obj)) { + continent.GUID(obj.Companion) match { + case Some(boomer: BoomerDeployable) => + boomer.Trigger = None + boomer.Actor ! Deployable.Deconstruct() + case Some(thing) => + log.warn(s"RequestDestroy: BoomerTrigger object connected to wrong object - $thing") + case None => () + } + } + + case _ => () + } + } + + def handleMoveItem(pkt: MoveItemMessage): Unit = { /* intentionally blank */ } + + def handleLootItem(pkt: LootItemMessage): Unit = { /* intentionally blank */ } + + def handleAvatarImplant(pkt: AvatarImplantMessage): Unit = { + val AvatarImplantMessage(_, _, slot, _) = pkt + customImplants.lift(slot) + .collect { + case implant if implant.active => + customImplants = customImplants.updated(slot, implant.copy(active = false)) + sendResponse(AvatarImplantMessage(player.GUID, ImplantAction.Activation, slot, 0)) + sendResponse(PlanetsideAttributeMessage(player.GUID, 28, implant.definition.implantType.value * 2)) + case implant => + customImplants = customImplants.updated(slot, implant.copy(active = true)) + sendResponse(AvatarImplantMessage(player.GUID, ImplantAction.Activation, slot, 1)) + sendResponse(PlanetsideAttributeMessage(player.GUID, 28, implant.definition.implantType.value * 2 + 1)) + } + } + + def handleUseItem(pkt: UseItemMessage): Unit = { + sessionLogic.validObject(pkt.object_guid, decorator = "UseItem") match { + case Some(door: Door) => + handleUseDoor(door, None) + case Some(obj: TelepadDeployable) => + handleUseTelepadDeployable(obj, None, pkt) + case Some(obj: Utility.InternalTelepad) => + handleUseInternalTelepad(obj, pkt) + case _ => () + } + } + + def handleUnuseItem(pkt: UnuseItemMessage): Unit = { /* intentionally blank */ } + + def handleDeployObject(pkt: DeployObjectMessage): Unit = { /* intentionally blank */ } + + def handlePlanetsideAttribute(pkt: PlanetsideAttributeMessage): Unit = { + val PlanetsideAttributeMessage(objectGuid, attributeType, _/*attributeValue*/) = pkt + sessionLogic.validObject(objectGuid, decorator = "PlanetsideAttribute") match { + case Some(_: Vehicle) => () + case Some(_: Player) if attributeType == 106 => () + case Some(obj) => + log.trace(s"PlanetsideAttribute: ${player.Name} does not know how to apply unknown attributes behavior $attributeType to ${obj.Definition.Name}") + case _ => () + } + } + + def handleGenericObjectAction(pkt: GenericObjectActionMessage): Unit = { + val GenericObjectActionMessage(objectGuid, _/*code*/) = pkt + sessionLogic.validObject(objectGuid, decorator = "GenericObjectAction") match { + case Some(_: Vehicle) => () + case Some(_: Tool) => () + case _ => log.info(s"${player.Name} - $pkt") + } + } + + def handleGenericObjectActionAtPosition(pkt: GenericObjectActionAtPositionMessage): Unit = { + val GenericObjectActionAtPositionMessage(objectGuid, _, _) = pkt + sessionLogic.validObject(objectGuid, decorator = "GenericObjectActionAtPosition") match { + case Some(tool: Tool) if GlobalDefinitions.isBattleFrameNTUSiphon(tool.Definition) => () + case _ => log.info(s"${player.Name} - $pkt") + } + } + + def handleGenericObjectState(pkt: GenericObjectStateMsg): Unit = { + val GenericObjectStateMsg(_, _) = pkt + log.info(s"${player.Name} - $pkt") + } + + def handleGenericAction(pkt: GenericActionMessage): Unit = { + val GenericActionMessage(action) = pkt + val (toolOpt, definition) = player.Slot(0).Equipment match { + case Some(tool: Tool) => + (Some(tool), tool.Definition) + case _ => + (None, GlobalDefinitions.bullet_9mm) + } + action match { + case GenericAction.MaxAnchorsExtend_RCV => + log.info(s"${player.Name} has anchored ${player.Sex.pronounObject}self to the ground") + player.UsingSpecial = SpecialExoSuitDefinition.Mode.Anchored + continent.AvatarEvents ! AvatarServiceMessage( + continent.id, + AvatarAction.PlanetsideAttribute(player.GUID, 19, 1) + ) + definition match { + case GlobalDefinitions.trhev_dualcycler | GlobalDefinitions.trhev_burster => + val tool = toolOpt.get + tool.ToFireMode = 1 + sendResponse(ChangeFireModeMessage(tool.GUID, 1)) + case GlobalDefinitions.trhev_pounder => + val tool = toolOpt.get + val convertFireModeIndex = if (tool.FireModeIndex == 0) { 1 } + else { 4 } + tool.ToFireMode = convertFireModeIndex + sendResponse(ChangeFireModeMessage(tool.GUID, convertFireModeIndex)) + case _ => + log.warn(s"GenericObject: ${player.Name} is a MAX with an unexpected attachment - ${definition.Name}") + } + case GenericAction.MaxAnchorsRelease_RCV => + log.info(s"${player.Name} has released the anchors") + player.UsingSpecial = SpecialExoSuitDefinition.Mode.Normal + continent.AvatarEvents ! AvatarServiceMessage( + continent.id, + AvatarAction.PlanetsideAttribute(player.GUID, 19, 0) + ) + definition match { + case GlobalDefinitions.trhev_dualcycler | GlobalDefinitions.trhev_burster => + val tool = toolOpt.get + tool.ToFireMode = 0 + sendResponse(ChangeFireModeMessage(tool.GUID, 0)) + case GlobalDefinitions.trhev_pounder => + val tool = toolOpt.get + val convertFireModeIndex = if (tool.FireModeIndex == 1) { 0 } else { 3 } + tool.ToFireMode = convertFireModeIndex + sendResponse(ChangeFireModeMessage(tool.GUID, convertFireModeIndex)) + case _ => + log.warn(s"GenericObject: $player is MAX with an unexpected attachment - ${definition.Name}") + } + case GenericAction.AwayFromKeyboard_RCV => + log.info(s"${player.Name} is AFK") + AvatarActor.savePlayerLocation(player) + ops.displayCharSavedMsgThenRenewTimer(fixedLen=1800L, varLen=0L) //~30min + player.AwayFromKeyboard = true + case GenericAction.BackInGame_RCV => + log.info(s"${player.Name} is back") + player.AwayFromKeyboard = false + ops.renewCharSavedTimer( + Config.app.game.savedMsg.renewal.fixed, + Config.app.game.savedMsg.renewal.variable + ) + case GenericAction.LookingForSquad_RCV => //Looking For Squad ON + if (!avatar.lookingForSquad && (sessionLogic.squad.squadUI.isEmpty || sessionLogic.squad.squadUI(player.CharId).index == 0)) { + avatarActor ! AvatarActor.SetLookingForSquad(true) + } + case GenericAction.NotLookingForSquad_RCV => //Looking For Squad OFF + if (avatar.lookingForSquad && (sessionLogic.squad.squadUI.isEmpty || sessionLogic.squad.squadUI(player.CharId).index == 0)) { + avatarActor ! AvatarActor.SetLookingForSquad(false) + } + case _ => + log.warn(s"GenericActionMessage: ${player.Name} can't handle $action") + } + } + + def handleGenericCollision(pkt: GenericCollisionMsg): Unit = { /* intentionally blank */ } + + def handleAvatarFirstTimeEvent(pkt: AvatarFirstTimeEventMessage): Unit = { /* intentionally blank */ } + + def handleBugReport(pkt: PlanetSideGamePacket): Unit = { + val BugReportMessage( + _/*versionMajor*/, + _/*versionMinor*/, + _/*versionDate*/, + _/*bugType*/, + _/*repeatable*/, + _/*location*/, + _/*zone*/, + _/*pos*/, + _/*summary*/, + _/*desc*/ + ) = pkt + log.warn(s"${player.Name} filed a bug report - it might be something important") + log.debug(s"$pkt") + } + + def handleFacilityBenefitShieldChargeRequest(pkt: FacilityBenefitShieldChargeRequestMessage): Unit = { /* intentionally blank */ } + + def handleBattleplan(pkt: BattleplanMessage): Unit = { + val BattleplanMessage(_, name, _, _) = pkt + val lament: String = s"$name has a brilliant idea that no one will ever see" + log.info(lament) + log.debug(s"Battleplan: $lament - $pkt") + } + + def handleBindPlayer(pkt: BindPlayerMessage): Unit = { + val BindPlayerMessage(_, _, _, _, _, _, _, _) = pkt + } + + def handleCreateShortcut(pkt: CreateShortcutMessage): Unit = { /* intentionally blank */ } + + def handleChangeShortcutBank(pkt: ChangeShortcutBankMessage): Unit = { /* intentionally blank */ } + + def handleFriendRequest(pkt: FriendsRequest): Unit = { + val FriendsRequest(action, name) = pkt + avatarActor ! AvatarActor.MemberListRequest(action, name) + } + + def handleInvalidTerrain(pkt: InvalidTerrainMessage): Unit = { /* intentionally blank */ } + + def handleActionCancel(pkt: ActionCancelMessage): Unit = { + val ActionCancelMessage(_, _, _) = pkt + ops.progressBarUpdate.cancel() + ops.progressBarValue = None + } + + def handleTrade(pkt: TradeMessage): Unit = { /* intentionally blank */ } + + def handleDisplayedAward(pkt: DisplayedAwardMessage): Unit = { + val DisplayedAwardMessage(_, ribbon, bar) = pkt + log.trace(s"${player.Name} changed the $bar displayed award ribbon to $ribbon") + avatarActor ! AvatarActor.SetRibbon(ribbon, bar) + } + + def handleObjectDetected(pkt: ObjectDetectedMessage): Unit = { /* intentionally blank */ } + + def handleTargetingImplantRequest(pkt: TargetingImplantRequest): Unit = { + val TargetingImplantRequest(list) = pkt + val targetInfo: List[TargetInfo] = list.flatMap { x => + continent.GUID(x.target_guid) match { + case Some(player: Player) => + val health = player.Health.toFloat / player.MaxHealth + val armor = if (player.MaxArmor > 0) { + player.Armor.toFloat / player.MaxArmor + } else { + 0 + } + Some(TargetInfo(player.GUID, health, armor)) + case _ => + log.warn( + s"TargetingImplantRequest: the info that ${player.Name} requested for target ${x.target_guid} is not for a player" + ) + None + } + } + sendResponse(TargetingInfoMessage(targetInfo)) + } + + def handleHitHint(pkt: HitHint): Unit = { /* intentionally blank */ } + + /* messages */ + + def handleSetAvatar(avatar: Avatar): Unit = { + session = session.copy(avatar = avatar) + if (session.player != null) { + session.player.avatar = avatar + } + LivePlayerList.Update(avatar.id, avatar) + } + + def handleReceiveAccountData(account: Account): Unit = { + log.trace(s"ReceiveAccountData $account") + session = session.copy(account = account) + avatarActor ! AvatarActor.SetAccount(account) + } + + def handleUseCooldownRenew: BasicDefinition => Unit = { + case _: KitDefinition => ops.kitToBeUsed = None + case _ => () + } + + def handleAvatarResponse(avatar: Avatar): Unit = { + session = session.copy(avatar = avatar) + sessionLogic.accountPersistence ! AccountPersistenceService.Login(avatar.name, avatar.id) + } + + def handleSetSpeed(speed: Float): Unit = { + session = session.copy(speed = speed) + } + + def handleSetFlying(flying: Boolean): Unit = { + session = session.copy(flying = flying) + } + + def handleSetSpectator(spectator: Boolean): Unit = { + session.player.spectator = spectator + } + + def handleKick(player: Player, time: Option[Long]): Unit = { + administrativeKick(player) + sessionLogic.accountPersistence ! AccountPersistenceService.Kick(player.Name, time) + } + + def handleSilenced(isSilenced: Boolean): Unit = { + player.silenced = isSilenced + } + + /* supporting functions */ + + private def handleUseDoor(door: Door, equipment: Option[Equipment]): Unit = { + equipment match { + case Some(tool: Tool) if tool.Definition == GlobalDefinitions.medicalapplicator => + val distance: Float = math.max( + Config.app.game.doorsCanBeOpenedByMedAppFromThisDistance, + door.Definition.initialOpeningDistance + ) + door.Actor ! CommonMessages.Use(player, Some(distance)) + case _ => + door.Actor ! CommonMessages.Use(player) + } + } + + private def handleUseTelepadDeployable(obj: TelepadDeployable, equipment: Option[Equipment], msg: UseItemMessage): Unit = { + if (equipment.isEmpty) { + (continent.GUID(obj.Router) match { + case Some(vehicle: Vehicle) => Some((vehicle, vehicle.Utility(UtilityType.internal_router_telepad_deployable))) + case Some(vehicle) => Some(vehicle, None) + case None => None + }) match { + case Some((vehicle: Vehicle, Some(util: Utility.InternalTelepad))) => + player.WhichSide = vehicle.WhichSide + useRouterTelepadSystem( + router = vehicle, + internalTelepad = util, + remoteTelepad = obj, + src = obj, + dest = util + ) + case Some((vehicle: Vehicle, None)) => + log.error( + s"telepad@${msg.object_guid.guid} is not linked to a router - ${vehicle.Definition.Name}" + ) + case Some((o, _)) => + log.error( + s"telepad@${msg.object_guid.guid} is linked to wrong kind of object - ${o.Definition.Name}, ${obj.Router}" + ) + obj.Actor ! Deployable.Deconstruct() + case _ => () + } + } + } + + private def handleUseInternalTelepad(obj: InternalTelepad, msg: UseItemMessage): Unit = { + continent.GUID(obj.Telepad) match { + case Some(pad: TelepadDeployable) => + player.WhichSide = pad.WhichSide + useRouterTelepadSystem( + router = obj.Owner.asInstanceOf[Vehicle], + internalTelepad = obj, + remoteTelepad = pad, + src = obj, + dest = pad + ) + case Some(o) => + log.error( + s"internal telepad@${msg.object_guid.guid} is not linked to a remote telepad - ${o.Definition.Name}@${o.GUID.guid}" + ) + case None => () + } + } + + /** + * Get the current `Vehicle` object that the player is riding/driving. + * The vehicle must be found solely through use of `player.VehicleSeated`. + * @return the vehicle + */ + private def findLocalVehicle: Option[Vehicle] = { + continent.GUID(player.VehicleSeated) match { + case Some(obj: Vehicle) => Some(obj) + case _ => None + } + } + + /** + * A simple object searching algorithm that is limited to containers currently known and accessible by the player. + * If all relatively local containers are checked and the object is not found, + * the player's locker inventory will be checked, and then + * the game environment (items on the ground) will be checked too. + * If the target object is discovered, it is removed from its current location and is completely destroyed. + * @see `RequestDestroyMessage` + * @see `Zone.ItemIs.Where` + * @param objectGuid the target object's globally unique identifier; + * it is not expected that the object will be unregistered, but it is also not gauranteed + * @param obj the target object + * @return `true`, if the target object was discovered and removed; + * `false`, otherwise + */ + private def findEquipmentToDelete(objectGuid: PlanetSideGUID, obj: Equipment): Boolean = { + val findFunc + : PlanetSideServerObject with Container => Option[(PlanetSideServerObject with Container, Option[Int])] = + ops.findInLocalContainer(objectGuid) + + findFunc(player) + .orElse(ops.accessedContainer match { + case Some(parent: PlanetSideServerObject) => + findFunc(parent) + case _ => + None + }) + .orElse(findLocalVehicle match { + case Some(parent: PlanetSideServerObject) => + findFunc(parent) + case _ => + None + }) match { + case Some((parent, Some(_))) => + obj.Position = Vector3.Zero + RemoveOldEquipmentFromInventory(parent)(obj) + true + case _ if player.avatar.locker.Inventory.Remove(objectGuid) => + sendResponse(ObjectDeleteMessage(objectGuid, 0)) + true + case _ if continent.EquipmentOnGround.contains(obj) => + obj.Position = Vector3.Zero + continent.Ground ! Zone.Ground.RemoveItem(objectGuid) + continent.AvatarEvents ! AvatarServiceMessage.Ground(RemoverActor.ClearSpecific(List(obj), continent)) + true + case _ => + Zone.EquipmentIs.Where(obj, objectGuid, continent) match { + case None => + true + case Some(Zone.EquipmentIs.Orphaned()) if obj.HasGUID => + TaskWorkflow.execute(GUIDTask.unregisterEquipment(continent.GUID, obj)) + true + case Some(Zone.EquipmentIs.Orphaned()) => + true + case _ => + log.warn(s"RequestDestroy: equipment $obj exists, but ${player.Name} can not reach it to dispose of it") + false + } + } + } + + /** + * A player uses a fully-linked Router teleportation system. + * @param router the Router vehicle + * @param internalTelepad the internal telepad within the Router vehicle + * @param remoteTelepad the remote telepad that is currently associated with this Router + * @param src the origin of the teleportation (where the player starts) + * @param dest the destination of the teleportation (where the player is going) + */ + private def useRouterTelepadSystem( + router: Vehicle, + internalTelepad: InternalTelepad, + remoteTelepad: TelepadDeployable, + src: PlanetSideGameObject with TelepadLike, + dest: PlanetSideGameObject with TelepadLike + ): Unit = { + val time = System.currentTimeMillis() + if ( + time - ops.recentTeleportAttempt > 2000L && router.DeploymentState == DriveState.Deployed && + internalTelepad.Active && + remoteTelepad.Active + ) { + val pguid = player.GUID + val sguid = src.GUID + val dguid = dest.GUID + sendResponse(PlayerStateShiftMessage(ShiftState(0, dest.Position, player.Orientation.z))) + ops.useRouterTelepadEffect(pguid, sguid, dguid) + continent.LocalEvents ! LocalServiceMessage( + continent.id, + LocalAction.RouterTelepadTransport(pguid, pguid, sguid, dguid) + ) + val vSource = VehicleSource(router) + val zoneNumber = continent.Number + player.LogActivity(VehicleMountActivity(vSource, PlayerSource(player), zoneNumber)) + player.Position = dest.Position + player.LogActivity(VehicleDismountActivity(vSource, PlayerSource(player), zoneNumber)) + } else { + log.warn(s"UseRouterTelepadSystem: ${player.Name} can not teleport") + } + ops.recentTeleportAttempt = time + } + + private def administrativeKick(tplayer: Player): Unit = { + log.warn(s"${tplayer.Name} has been kicked by ${player.Name}") + tplayer.death_by = -1 + sessionLogic.accountPersistence ! AccountPersistenceService.Kick(tplayer.Name) + } +} diff --git a/src/main/scala/net/psforever/actors/session/spectator/LocalHandlerLogic.scala b/src/main/scala/net/psforever/actors/session/spectator/LocalHandlerLogic.scala new file mode 100644 index 000000000..e0406f564 --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/spectator/LocalHandlerLogic.scala @@ -0,0 +1,248 @@ +// Copyright (c) 2024 PSForever +package net.psforever.actors.session.spectator + +import akka.actor.ActorContext +import net.psforever.actors.session.support.{LocalHandlerFunctions, SessionData, SessionLocalHandlers} +import net.psforever.objects.ce.Deployable +import net.psforever.objects.vehicles.MountableWeapons +import net.psforever.objects.{BoomerDeployable, ExplosiveDeployable, TelepadDeployable, Tool, TurretDeployable} +import net.psforever.packet.game.{ChatMsg, DeployableObjectsInfoMessage, GenericActionMessage, GenericObjectActionMessage, GenericObjectStateMsg, HackMessage, HackState, InventoryStateMessage, ObjectAttachMessage, ObjectCreateMessage, ObjectDeleteMessage, ObjectDetachMessage, OrbitalShuttleTimeMsg, PadAndShuttlePair, PlanetsideAttributeMessage, ProximityTerminalUseMessage, SetEmpireMessage, TriggerEffectMessage, TriggerSoundMessage, TriggeredSound, VehicleStateMessage} +import net.psforever.services.Service +import net.psforever.services.local.LocalResponse +import net.psforever.types.{ChatMessageType, PlanetSideGUID, Vector3} + +object LocalHandlerLogic { + def apply(ops: SessionLocalHandlers): LocalHandlerLogic = { + new LocalHandlerLogic(ops, ops.context) + } +} + +class LocalHandlerLogic(val ops: SessionLocalHandlers, implicit val context: ActorContext) extends LocalHandlerFunctions { + def sessionLogic: SessionData = ops.sessionLogic + + /* response handlers */ + + /** + * na + * @param toChannel na + * @param guid na + * @param reply na + */ + def handle(toChannel: String, guid: PlanetSideGUID, reply: LocalResponse.Response): Unit = { + val resolvedPlayerGuid = if (player.HasGUID) { + player.GUID + } else { + Service.defaultPlayerGUID + } + val isNotSameTarget = resolvedPlayerGuid != guid + reply match { + case LocalResponse.DeployableMapIcon(behavior, deployInfo) if isNotSameTarget => + sendResponse(DeployableObjectsInfoMessage(behavior, deployInfo)) + + case LocalResponse.DeployableUIFor(item) => + sessionLogic.general.updateDeployableUIElements(avatar.deployables.UpdateUIElement(item)) + + case LocalResponse.Detonate(dguid, _: BoomerDeployable) => + sendResponse(TriggerEffectMessage(dguid, "detonate_boomer")) + sendResponse(PlanetsideAttributeMessage(dguid, attribute_type=29, attribute_value=1)) + sendResponse(ObjectDeleteMessage(dguid, unk1=0)) + + case LocalResponse.Detonate(dguid, _: ExplosiveDeployable) => + sendResponse(GenericObjectActionMessage(dguid, code=19)) + sendResponse(PlanetsideAttributeMessage(dguid, attribute_type=29, attribute_value=1)) + sendResponse(ObjectDeleteMessage(dguid, unk1=0)) + + case LocalResponse.Detonate(_, obj) => + log.warn(s"LocalResponse.Detonate: ${obj.Definition.Name} not configured to explode correctly") + + case LocalResponse.DoorOpens(doorGuid) if isNotSameTarget => + sendResponse(GenericObjectStateMsg(doorGuid, state=16)) + + case LocalResponse.DoorCloses(doorGuid) => //door closes for everyone + sendResponse(GenericObjectStateMsg(doorGuid, state=17)) + + case LocalResponse.EliminateDeployable(obj: TurretDeployable, dguid, _, _) if obj.Destroyed => + sendResponse(ObjectDeleteMessage(dguid, unk1=0)) + + case LocalResponse.EliminateDeployable(obj: TurretDeployable, dguid, pos, _) => + obj.Destroyed = true + DeconstructDeployable( + obj, + dguid, + pos, + obj.Orientation, + deletionType= if (obj.MountPoints.isEmpty) { 2 } else { 1 } + ) + + case LocalResponse.EliminateDeployable(obj: ExplosiveDeployable, dguid, _, _) + if obj.Destroyed || obj.Jammed || obj.Health == 0 => + sendResponse(ObjectDeleteMessage(dguid, unk1=0)) + + case LocalResponse.EliminateDeployable(obj: ExplosiveDeployable, dguid, pos, effect) => + obj.Destroyed = true + DeconstructDeployable(obj, dguid, pos, obj.Orientation, effect) + + case LocalResponse.EliminateDeployable(obj: TelepadDeployable, dguid, _, _) if obj.Active && obj.Destroyed => + //if active, deactivate + obj.Active = false + sendResponse(GenericObjectActionMessage(dguid, code=29)) + sendResponse(GenericObjectActionMessage(dguid, code=30)) + //standard deployable elimination behavior + sendResponse(ObjectDeleteMessage(dguid, unk1=0)) + + case LocalResponse.EliminateDeployable(obj: TelepadDeployable, dguid, pos, _) if obj.Active => + //if active, deactivate + obj.Active = false + sendResponse(GenericObjectActionMessage(dguid, code=29)) + sendResponse(GenericObjectActionMessage(dguid, code=30)) + //standard deployable elimination behavior + obj.Destroyed = true + DeconstructDeployable(obj, dguid, pos, obj.Orientation, deletionType=2) + + case LocalResponse.EliminateDeployable(obj: TelepadDeployable, dguid, _, _) if obj.Destroyed => + //standard deployable elimination behavior + sendResponse(ObjectDeleteMessage(dguid, unk1=0)) + + case LocalResponse.EliminateDeployable(obj: TelepadDeployable, dguid, pos, _) => + //standard deployable elimination behavior + obj.Destroyed = true + DeconstructDeployable(obj, dguid, pos, obj.Orientation, deletionType=2) + + case LocalResponse.EliminateDeployable(obj, dguid, _, _) if obj.Destroyed => + sendResponse(ObjectDeleteMessage(dguid, unk1=0)) + + case LocalResponse.EliminateDeployable(obj, dguid, pos, effect) => + obj.Destroyed = true + DeconstructDeployable(obj, dguid, pos, obj.Orientation, effect) + + case LocalResponse.SendHackMessageHackCleared(targetGuid, unk1, unk2) => + sendResponse(HackMessage(unk1=0, targetGuid, guid, progress=0, unk1, HackState.HackCleared, unk2)) + + case LocalResponse.HackObject(targetGuid, unk1, unk2) => + sessionLogic.general.hackObject(targetGuid, unk1, unk2) + + case LocalResponse.PlanetsideAttribute(targetGuid, attributeType, attributeValue) => + sessionLogic.general.sendPlanetsideAttributeMessage(targetGuid, attributeType, attributeValue) + + case LocalResponse.GenericObjectAction(targetGuid, actionNumber) => + sendResponse(GenericObjectActionMessage(targetGuid, actionNumber)) + + case LocalResponse.GenericActionMessage(actionNumber) => + sendResponse(GenericActionMessage(actionNumber)) + + case LocalResponse.ChatMessage(msg) => + sendResponse(msg) + + case LocalResponse.SendPacket(packet) => + sendResponse(packet) + + case LocalResponse.LluSpawned(llu) => + // Create LLU on client + sendResponse(ObjectCreateMessage( + llu.Definition.ObjectId, + llu.GUID, + llu.Definition.Packet.ConstructorData(llu).get + )) + sendResponse(TriggerSoundMessage(TriggeredSound.LLUMaterialize, llu.Position, unk=20, volume=0.8000001f)) + + case LocalResponse.LluDespawned(lluGuid, position) => + sendResponse(TriggerSoundMessage(TriggeredSound.LLUDeconstruct, position, unk=20, volume=0.8000001f)) + sendResponse(ObjectDeleteMessage(lluGuid, unk1=0)) + // If the player was holding the LLU, remove it from their tracked special item slot + sessionLogic.general.specialItemSlotGuid.collect { case guid if guid == lluGuid => + sessionLogic.general.specialItemSlotGuid = None + player.Carrying = None + } + + case LocalResponse.ObjectDelete(objectGuid, unk) if isNotSameTarget => + sendResponse(ObjectDeleteMessage(objectGuid, unk)) + + case LocalResponse.ProximityTerminalEffect(object_guid, true) => + sendResponse(ProximityTerminalUseMessage(Service.defaultPlayerGUID, object_guid, unk=true)) + + case LocalResponse.ProximityTerminalEffect(objectGuid, false) => + sendResponse(ProximityTerminalUseMessage(Service.defaultPlayerGUID, objectGuid, unk=false)) + sessionLogic.terminals.ForgetAllProximityTerminals(objectGuid) + + case LocalResponse.RouterTelepadMessage(msg) => + sendResponse(ChatMsg(ChatMessageType.UNK_229, wideContents=false, recipient="", msg, note=None)) + + case LocalResponse.RouterTelepadTransport(passengerGuid, srcGuid, destGuid) => + sessionLogic.general.useRouterTelepadEffect(passengerGuid, srcGuid, destGuid) + + case LocalResponse.SendResponse(msg) => + sendResponse(msg) + + case LocalResponse.SetEmpire(objectGuid, empire) => + sendResponse(SetEmpireMessage(objectGuid, empire)) + + case LocalResponse.ShuttleEvent(ev) => + val msg = OrbitalShuttleTimeMsg( + ev.u1, + ev.u2, + ev.t1, + ev.t2, + ev.t3, + pairs=ev.pairs.map { case ((a, b), c) => PadAndShuttlePair(a, b, c) } + ) + sendResponse(msg) + + case LocalResponse.ShuttleDock(pguid, sguid, slot) => + sendResponse(ObjectAttachMessage(pguid, sguid, slot)) + + case LocalResponse.ShuttleUndock(pguid, sguid, pos, orient) => + sendResponse(ObjectDetachMessage(pguid, sguid, pos, orient)) + + case LocalResponse.ShuttleState(sguid, pos, orient, state) => + sendResponse(VehicleStateMessage(sguid, unk1=0, pos, orient, vel=None, Some(state), unk3=0, unk4=0, wheel_direction=15, is_decelerating=false, is_cloaked=false)) + + case LocalResponse.ToggleTeleportSystem(router, systemPlan) => + sessionLogic.general.toggleTeleportSystem(router, systemPlan) + + case LocalResponse.TriggerEffect(targetGuid, effect, effectInfo, triggerLocation) => + sendResponse(TriggerEffectMessage(targetGuid, effect, effectInfo, triggerLocation)) + + case LocalResponse.TriggerSound(sound, pos, unk, volume) => + sendResponse(TriggerSoundMessage(sound, pos, unk, volume)) + + case LocalResponse.UpdateForceDomeStatus(buildingGuid, true) => + sendResponse(GenericObjectActionMessage(buildingGuid, 11)) + + case LocalResponse.UpdateForceDomeStatus(buildingGuid, false) => + sendResponse(GenericObjectActionMessage(buildingGuid, 12)) + + case LocalResponse.RechargeVehicleWeapon(vehicleGuid, weaponGuid) if resolvedPlayerGuid == guid => + continent.GUID(vehicleGuid) + .collect { case vehicle: MountableWeapons => (vehicle, vehicle.PassengerInSeat(player)) } + .collect { case (vehicle, Some(seat_num)) => vehicle.WeaponControlledFromSeat(seat_num) } + .getOrElse(Set.empty) + .collect { case weapon: Tool if weapon.GUID == weaponGuid => + sendResponse(InventoryStateMessage(weapon.AmmoSlot.Box.GUID, weapon.GUID, weapon.Magazine)) + } + + case _ => () + } + } + + /* support functions */ + + /** + * Common behavior for deconstructing deployables in the game environment. + * @param obj the deployable + * @param guid the globally unique identifier for the deployable + * @param pos the previous position of the deployable + * @param orient the previous orientation of the deployable + * @param deletionType the value passed to `ObjectDeleteMessage` concerning the deconstruction animation + */ + def DeconstructDeployable( + obj: Deployable, + guid: PlanetSideGUID, + pos: Vector3, + orient: Vector3, + deletionType: Int + ): Unit = { + sendResponse(TriggerEffectMessage("spawn_object_failed_effect", pos, orient)) + sendResponse(PlanetsideAttributeMessage(guid, 29, 1)) //make deployable vanish + sendResponse(ObjectDeleteMessage(guid, deletionType)) + } +} diff --git a/src/main/scala/net/psforever/actors/session/spectator/MountHandlerLogic.scala b/src/main/scala/net/psforever/actors/session/spectator/MountHandlerLogic.scala new file mode 100644 index 000000000..f5a6caea8 --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/spectator/MountHandlerLogic.scala @@ -0,0 +1,302 @@ +// Copyright (c) 2024 PSForever +package net.psforever.actors.session.spectator + +import akka.actor.ActorContext +import net.psforever.actors.session.SessionActor +import net.psforever.actors.session.normal.NormalMode +import net.psforever.actors.session.support.{MountHandlerFunctions, SessionData, SessionMountHandlers} +import net.psforever.actors.zone.ZoneActor +import net.psforever.objects.{GlobalDefinitions, PlanetSideGameObject, Player, Vehicle, Vehicles} +import net.psforever.objects.serverobject.affinity.FactionAffinity +import net.psforever.objects.serverobject.mount.Mountable +import net.psforever.objects.serverobject.terminals.implant.ImplantTerminalMech +import net.psforever.objects.vehicles.{AccessPermissionGroup, CargoBehavior} +import net.psforever.objects.vital.InGameHistory +import net.psforever.packet.game.{DelayedPathMountMsg, DismountVehicleCargoMsg, DismountVehicleMsg, GenericObjectActionMessage, MountVehicleCargoMsg, MountVehicleMsg, ObjectDetachMessage, PlayerStasisMessage, PlayerStateShiftMessage, ShiftState} +import net.psforever.services.Service +import net.psforever.services.local.{LocalAction, LocalServiceMessage} +import net.psforever.services.vehicle.{VehicleAction, VehicleServiceMessage} +import net.psforever.types.{BailType, PlanetSideGUID, Vector3} + +object MountHandlerLogic { + def apply(ops: SessionMountHandlers): MountHandlerLogic = { + new MountHandlerLogic(ops, ops.context) + } +} + +class MountHandlerLogic(val ops: SessionMountHandlers, implicit val context: ActorContext) extends MountHandlerFunctions { + def sessionLogic: SessionData = ops.sessionLogic + + /* packets */ + + def handleMountVehicle(pkt: MountVehicleMsg): Unit = { /* intentionally blank */ } + + def handleDismountVehicle(pkt: DismountVehicleMsg): Unit = { + val DismountVehicleMsg(player_guid, bailType, wasKickedByDriver) = pkt + val dError: (String, Player)=> Unit = dismountError(bailType, wasKickedByDriver) + //TODO optimize this later + //common warning for this section + if (player.GUID == player_guid) { + //normally disembarking from a mount + (sessionLogic.zoning.interstellarFerry.orElse(continent.GUID(player.VehicleSeated)) match { + case out @ Some(obj: Vehicle) => + continent.GUID(obj.MountedIn) match { + case Some(_: Vehicle) => None //cargo vehicle + case _ => out //arrangement "may" be permissible + } + case out @ Some(_: Mountable) => + out + case _ => + dError(s"DismountVehicleMsg: player ${player.Name} not considered seated in a mountable entity", player) + None + }) match { + case Some(obj: Mountable) => + obj.PassengerInSeat(player) match { + case Some(seat_num) => + obj.Actor ! Mountable.TryDismount(player, seat_num, bailType) + //short-circuit the temporary channel for transferring between zones, the player is no longer doing that + sessionLogic.zoning.interstellarFerry = None + // Deconstruct the vehicle if the driver has bailed out and the vehicle is capable of flight + //todo: implement auto landing procedure if the pilot bails but passengers are still present instead of deconstructing the vehicle + //todo: continue flight path until aircraft crashes if no passengers present (or no passenger seats), then deconstruct. + //todo: kick cargo passengers out. To be added after PR #216 is merged + obj match { + case v: Vehicle + if bailType == BailType.Bailed && + v.SeatPermissionGroup(seat_num).contains(AccessPermissionGroup.Driver) && + v.isFlying => + v.Actor ! Vehicle.Deconstruct(None) //immediate deconstruction + case _ => ; + } + + case None => + dError(s"DismountVehicleMsg: can not find where player ${player.Name}_guid is seated in mountable ${player.VehicleSeated}", player) + } + case _ => + dError(s"DismountVehicleMsg: can not find mountable entity ${player.VehicleSeated}", player) + } + } else { + //kicking someone else out of a mount; need to own that mount/mountable + val dWarn: (String, Player)=> Unit = dismountWarning(bailType, wasKickedByDriver) + player.avatar.vehicle match { + case Some(obj_guid) => + ( + ( + sessionLogic.validObject(obj_guid, decorator = "DismountVehicle/Vehicle"), + sessionLogic.validObject(player_guid, decorator = "DismountVehicle/Player") + ) match { + case (vehicle @ Some(obj: Vehicle), tplayer) => + if (obj.MountedIn.isEmpty) (vehicle, tplayer) else (None, None) + case (mount @ Some(_: Mountable), tplayer) => + (mount, tplayer) + case _ => + (None, None) + }) match { + case (Some(obj: Mountable), Some(tplayer: Player)) => + obj.PassengerInSeat(tplayer) match { + case Some(seat_num) => + obj.Actor ! Mountable.TryDismount(tplayer, seat_num, bailType) + case None => + dError(s"DismountVehicleMsg: can not find where other player ${tplayer.Name} is seated in mountable $obj_guid", tplayer) + } + case (None, _) => + dWarn(s"DismountVehicleMsg: ${player.Name} can not find his vehicle", player) + case (_, None) => + dWarn(s"DismountVehicleMsg: player $player_guid could not be found to kick, ${player.Name}", player) + case _ => + dWarn(s"DismountVehicleMsg: object is either not a Mountable or not a Player", player) + } + case None => + dWarn(s"DismountVehicleMsg: ${player.Name} does not own a vehicle", player) + } + } + } + + def handleMountVehicleCargo(pkt: MountVehicleCargoMsg): Unit = { /* intentionally blank */ } + + def handleDismountVehicleCargo(pkt: DismountVehicleCargoMsg): Unit = { + val DismountVehicleCargoMsg(_, cargo_guid, bailed, _, kicked) = pkt + continent.GUID(cargo_guid) match { + case Some(cargo: Vehicle) => + cargo.Actor ! CargoBehavior.StartCargoDismounting(bailed || kicked) + case _ => () + } + } + + /* response handlers */ + + /** + * na + * + * @param tplayer na + * @param reply na + */ + def handle(tplayer: Player, reply: Mountable.Exchange): Unit = { + reply match { + case Mountable.CanDismount(obj: ImplantTerminalMech, seatNum, _) => + DismountAction(tplayer, obj, seatNum) + obj.Zone.actor ! ZoneActor.RemoveFromBlockMap(player) + + case Mountable.CanDismount(obj: Vehicle, _, mountPoint) + if obj.Definition == GlobalDefinitions.orbital_shuttle && obj.MountedIn.nonEmpty => + //dismount to hart lobby + val pguid = player.GUID + val sguid = obj.GUID + val (pos, zang) = Vehicles.dismountShuttle(obj, mountPoint) + tplayer.Position = pos + sendResponse(DelayedPathMountMsg(pguid, sguid, u1=60, u2=true)) + continent.LocalEvents ! LocalServiceMessage( + continent.id, + LocalAction.SendResponse(ObjectDetachMessage(sguid, pguid, pos, roll=0, pitch=0, zang)) + ) + obj.Zone.actor ! ZoneActor.RemoveFromBlockMap(player) + sessionLogic.keepAliveFunc = sessionLogic.zoning.NormalKeepAlive + + case Mountable.CanDismount(obj: Vehicle, seatNum, _) + if obj.Definition == GlobalDefinitions.orbital_shuttle => + //get ready for orbital drop + val pguid = player.GUID + val events = continent.VehicleEvents + DismountAction(tplayer, obj, seatNum) + continent.actor ! ZoneActor.RemoveFromBlockMap(player) //character doesn't need it + //DismountAction(...) uses vehicle service, so use that service to coordinate the remainder of the messages + events ! VehicleServiceMessage( + player.Name, + VehicleAction.SendResponse(Service.defaultPlayerGUID, PlayerStasisMessage(pguid)) //the stasis message + ) + //when the player dismounts, they will be positioned where the shuttle was when it disappeared in the sky + //the player will fall to the ground and is perfectly vulnerable in this state + //additionally, our player must exist in the current zone + //having no in-game avatar target will throw us out of the map screen when deploying and cause softlock + events ! VehicleServiceMessage( + player.Name, + VehicleAction.SendResponse( + Service.defaultPlayerGUID, + PlayerStateShiftMessage(ShiftState(unk=0, obj.Position, obj.Orientation.z, vel=None)) //cower in the shuttle bay + ) + ) + events ! VehicleServiceMessage( + continent.id, + VehicleAction.SendResponse(pguid, GenericObjectActionMessage(pguid, code=9)) //conceal the player + ) + context.self ! SessionActor.SetMode(NormalMode) + sessionLogic.keepAliveFunc = sessionLogic.zoning.NormalKeepAlive + + case Mountable.CanDismount(obj: Vehicle, seatNum, _) + if obj.Definition == GlobalDefinitions.droppod => + sessionLogic.general.unaccessContainer(obj) + DismountAction(tplayer, obj, seatNum) + obj.Actor ! Vehicle.Deconstruct() + + case Mountable.CanDismount(obj: Vehicle, seatNum, _) + if tplayer.GUID == player.GUID => + sessionLogic.vehicles.ConditionalDriverVehicleControl(obj) + sessionLogic.general.unaccessContainer(obj) + DismountVehicleAction(tplayer, obj, seatNum) + + case Mountable.CanDismount(obj: Vehicle, seat_num, _) => + continent.VehicleEvents ! VehicleServiceMessage( + continent.id, + VehicleAction.KickPassenger(tplayer.GUID, seat_num, unk2=true, obj.GUID) + ) + + case Mountable.CanDismount(obj: PlanetSideGameObject with PlanetSideGameObject with Mountable with FactionAffinity with InGameHistory, seatNum, _) => + DismountAction(tplayer, obj, seatNum) + + case Mountable.CanDismount(obj: Mountable, _, _) => () + + case Mountable.CanNotDismount(obj: Vehicle, seatNum) => + obj.Actor ! Vehicle.Deconstruct() + + case _ => () + } + } + + /* support functions */ + + private def dismountWarning( + bailAs: BailType.Value, + kickedByDriver: Boolean + ) + ( + note: String, + player: Player + ): Unit = { + log.warn(note) + player.VehicleSeated = None + sendResponse(DismountVehicleMsg(player.GUID, bailAs, kickedByDriver)) + } + + private def dismountError( + bailAs: BailType.Value, + kickedByDriver: Boolean + ) + ( + note: String, + player: Player + ): Unit = { + log.error(s"$note; some vehicle might not know that ${player.Name} is no longer sitting in it") + player.VehicleSeated = None + sendResponse(DismountVehicleMsg(player.GUID, bailAs, kickedByDriver)) + } + + /** + * Common activities/procedure when a player dismounts a valid mountable object. + * @param tplayer the player + * @param obj the mountable object + * @param seatNum the mount out of which which the player is disembarking + */ + private def DismountVehicleAction(tplayer: Player, obj: PlanetSideGameObject with FactionAffinity with InGameHistory, seatNum: Int): Unit = { + DismountAction(tplayer, obj, seatNum) + //until vehicles maintain synchronized momentum without a driver + obj match { + case v: Vehicle + if seatNum == 0 && Vector3.MagnitudeSquared(v.Velocity.getOrElse(Vector3.Zero)) > 0f => + sessionLogic.vehicles.serverVehicleControlVelocity.collect { _ => + sessionLogic.vehicles.ServerVehicleOverrideStop(v) + } + v.Velocity = Vector3.Zero + continent.VehicleEvents ! VehicleServiceMessage( + continent.id, + VehicleAction.VehicleState( + tplayer.GUID, + v.GUID, + unk1 = 0, + v.Position, + v.Orientation, + vel = None, + v.Flying, + unk3 = 0, + unk4 = 0, + wheel_direction = 15, + unk5 = false, + unk6 = v.Cloaked + ) + ) + v.Zone.actor ! ZoneActor.RemoveFromBlockMap(player) + case _ => () + } + } + + /** + * Common activities/procedure when a player dismounts a valid mountable object. + * @param tplayer the player + * @param obj the mountable object + * @param seatNum the mount out of which which the player is disembarking + */ + private def DismountAction(tplayer: Player, obj: PlanetSideGameObject with FactionAffinity with InGameHistory, seatNum: Int): Unit = { + val playerGuid: PlanetSideGUID = tplayer.GUID + tplayer.ContributionFrom(obj) + sessionLogic.keepAliveFunc = sessionLogic.zoning.NormalKeepAlive + val bailType = if (tplayer.BailProtection) { + BailType.Bailed + } else { + BailType.Normal + } + sendResponse(DismountVehicleMsg(playerGuid, bailType, wasKickedByDriver = false)) + continent.VehicleEvents ! VehicleServiceMessage( + continent.id, + VehicleAction.DismountVehicle(playerGuid, bailType, unk2 = false) + ) + } +} diff --git a/src/main/scala/net/psforever/actors/session/spectator/SpectatorMode.scala b/src/main/scala/net/psforever/actors/session/spectator/SpectatorMode.scala new file mode 100644 index 000000000..60476cda5 --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/spectator/SpectatorMode.scala @@ -0,0 +1,661 @@ +// Copyright (c) 2024 PSForever +package net.psforever.actors.session.spectator + +import akka.actor.Actor.Receive +import akka.actor.ActorRef +import net.psforever.actors.session.support.{AvatarHandlerFunctions, GalaxyHandlerFunctions, GeneralFunctions, LocalHandlerFunctions, MountHandlerFunctions, SquadHandlerFunctions, TerminalHandlerFunctions, VehicleFunctions, VehicleHandlerFunctions, WeaponAndProjectileFunctions} +import net.psforever.actors.zone.ZoneActor +import net.psforever.objects.avatar.{BattleRank, CommandRank, DeployableToolbox, FirstTimeEvents, Implant, ProgressDecoration, Shortcut => AvatarShortcut} +import net.psforever.objects.serverobject.ServerObject +import net.psforever.objects.{GlobalDefinitions, Player, Session, SimpleItem, Vehicle} +import net.psforever.packet.PlanetSidePacket +import net.psforever.packet.game.{ObjectCreateDetailedMessage, ObjectDeleteMessage} +import net.psforever.packet.game.objectcreate.{ObjectClass, ObjectCreateMessageParent, RibbonBars} +import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage} +import net.psforever.services.teamwork.{SquadAction, SquadServiceMessage} +import net.psforever.types.{CapacitorStateType, ChatMessageType, ExoSuitType, MeritCommendation, SquadRequestType} +// +import net.psforever.actors.session.{AvatarActor, SessionActor} +import net.psforever.actors.session.support.{ModeLogic, PlayerMode, SessionData, ZoningOperations} +import net.psforever.objects.TurretDeployable +import net.psforever.objects.guid.{GUIDTask, TaskWorkflow} +import net.psforever.objects.serverobject.CommonMessages +import net.psforever.objects.serverobject.containable.Containable +import net.psforever.objects.serverobject.deploy.Deployment +import net.psforever.objects.serverobject.mount.Mountable +import net.psforever.objects.serverobject.terminals.{ProximityUnit, Terminal} +import net.psforever.objects.zones.Zone +import net.psforever.packet.PlanetSideGamePacket +import net.psforever.packet.game.{AIDamage, ActionCancelMessage, AvatarFirstTimeEventMessage, AvatarGrenadeStateMessage, AvatarImplantMessage, AvatarJumpMessage, BattleplanMessage, BeginZoningMessage, BindPlayerMessage, BugReportMessage, ChangeAmmoMessage, ChangeFireModeMessage, ChangeFireStateMessage_Start, ChangeFireStateMessage_Stop, ChangeShortcutBankMessage, CharacterCreateRequestMessage, CharacterRequestMessage, ChatMsg, ChildObjectStateMessage, ConnectToWorldRequestMessage, CreateShortcutMessage, DeployObjectMessage, DeployRequestMessage, DismountVehicleCargoMsg, DismountVehicleMsg, DisplayedAwardMessage, DropItemMessage, DroppodLaunchRequestMessage, EmoteMsg, FacilityBenefitShieldChargeRequestMessage, FavoritesRequest, FrameVehicleStateMessage, FriendsRequest, GenericActionMessage, GenericCollisionMsg, GenericObjectActionAtPositionMessage, GenericObjectActionMessage, GenericObjectStateMsg, HitHint, HitMessage, InvalidTerrainMessage, ItemTransactionMessage, KeepAliveMessage, LashMessage, LongRangeProjectileInfoMessage, LootItemMessage, MountVehicleCargoMsg, MountVehicleMsg, MoveItemMessage, ObjectDetectedMessage, ObjectHeldMessage, OutfitRequest, PickupItemMessage, PlanetsideAttributeMessage, PlayerStateMessageUpstream, ProjectileStateMessage, ProximityTerminalUseMessage, ReleaseAvatarRequestMessage, ReloadMessage, RequestDestroyMessage, SetChatFilterMessage, SpawnRequestMessage, SplashHitMessage, SquadDefinitionActionMessage, SquadMembershipRequest, SquadWaypointRequest, TargetingImplantRequest, TradeMessage, UnuseItemMessage, UplinkRequest, UseItemMessage, VehicleStateMessage, VehicleSubStateMessage, VoiceHostInfo, VoiceHostRequest, WarpgateRequest, WeaponDelayFireMessage, WeaponDryFireMessage, WeaponFireMessage, WeaponLazeTargetPositionMessage, ZipLineMessage} +import net.psforever.services.{InterstellarClusterService => ICS} +import net.psforever.services.CavernRotationService +import net.psforever.services.CavernRotationService.SendCavernRotationUpdates +import net.psforever.services.ServiceManager.LookupResult +import net.psforever.services.account.{PlayerToken, ReceiveAccountData} +import net.psforever.services.avatar.AvatarServiceResponse +import net.psforever.services.galaxy.GalaxyServiceResponse +import net.psforever.services.local.LocalServiceResponse +import net.psforever.services.teamwork.SquadServiceResponse +import net.psforever.services.vehicle.VehicleServiceResponse + +class SpectatorModeLogic(data: SessionData) extends ModeLogic { + val avatarResponse: AvatarHandlerFunctions = AvatarHandlerLogic(data.avatarResponse) + val galaxy: GalaxyHandlerFunctions = GalaxyHandlerLogic(data.galaxyResponseHandlers) + val general: GeneralFunctions = GeneralLogic(data.general) + val local: LocalHandlerFunctions = LocalHandlerLogic(data.localResponse) + val mountResponse: MountHandlerFunctions = MountHandlerLogic(data.mountResponse) + val shooting: WeaponAndProjectileFunctions = WeaponAndProjectileLogic(data.shooting) + val squad: SquadHandlerFunctions = SquadHandlerLogic(data.squad) + val terminals: TerminalHandlerFunctions = TerminalHandlerLogic(data.terminals) + val vehicles: VehicleFunctions = VehicleLogic(data.vehicles) + val vehicleResponse: VehicleHandlerFunctions = VehicleHandlerLogic(data.vehicleResponseOperations) + + override def switchTo(session: Session): Unit = { + val player = session.player + val continent = session.zone + val pguid = player.GUID + val sendResponse: PlanetSidePacket=>Unit = data.sendResponse + // + sendResponse(ChatMsg(ChatMessageType.CMT_TOGGLESPECTATORMODE, "on")) + sendResponse(ChatMsg(ChatMessageType.UNK_227, "@SpectatorEnabled")) + continent.actor ! ZoneActor.RemoveFromBlockMap(player) + data.general.avatarActor ! AvatarActor.DeactivateActiveImplants() + continent + .GUID(data.terminals.usingMedicalTerminal) + .foreach { case term: Terminal with ProximityUnit => + data.terminals.StopUsingProximityUnit(term) + } + data.general.accessedContainer + .collect { + case veh: Vehicle if player.VehicleSeated.isEmpty || player.VehicleSeated.get != veh.GUID => + sendResponse(UnuseItemMessage(pguid, veh.GUID)) + sendResponse(UnuseItemMessage(pguid, pguid)) + data.general.unaccessContainer(veh) + case container => //just in case + if (player.VehicleSeated.isEmpty || player.VehicleSeated.get != container.GUID) { + // Ensure we don't close the container if the player is seated in it + // If the container is a corpse and gets removed just as this runs it can cause a client disconnect, so we'll check the container has a GUID first. + if (container.HasGUID) { + sendResponse(UnuseItemMessage(pguid, container.GUID)) + } + sendResponse(UnuseItemMessage(pguid, pguid)) + data.general.unaccessContainer(container) + } + } + player.CapacitorState = CapacitorStateType.Idle + player.Capacitor = 0f + player.Inventory.Items + .foreach { entry => sendResponse(ObjectDeleteMessage(entry.GUID, 0)) } + sendResponse(ObjectDeleteMessage(player.avatar.locker.GUID, 0)) + continent.AvatarEvents ! AvatarServiceMessage(continent.id, AvatarAction.ObjectDelete(pguid, pguid)) + player.Holsters() + .collect { case slot if slot.Equipment.nonEmpty => sendResponse(ObjectDeleteMessage(slot.Equipment.get.GUID, 0)) } + val vehicleAndSeat = data.vehicles.GetMountableAndSeat(None, player, continent) match { + case (Some(obj: Vehicle), Some(seatNum)) if seatNum == 0 => + data.vehicles.ServerVehicleOverrideStop(obj) + obj.Actor ! ServerObject.AttributeMsg(10, 3) //faction-accessible driver seat + obj.Actor ! Mountable.TryDismount(player, seatNum) + Some(ObjectCreateMessageParent(obj.GUID, seatNum)) + case (Some(obj), Some(seatNum)) => + obj.Actor ! Mountable.TryDismount(player, seatNum) + Some(ObjectCreateMessageParent(obj.GUID, seatNum)) + case _ => + None + } + data.general.dropSpecialSlotItem() + data.general.toggleMaxSpecialState(enable = false) + data.terminals.CancelAllProximityUnits() + data.terminals.lastTerminalOrderFulfillment = true + data.squadService ! SquadServiceMessage( + player, + continent, + SquadAction.Membership(SquadRequestType.Leave, player.CharId, Some(player.CharId), player.Name, None) + ) + val originalEvent = player.History.headOption + player.ClearHistory() + player.LogActivity(originalEvent) + player.spectator = true + // + val newPlayer = SpectatorModeLogic.spectatorCharacter(player) + val cud = new SimpleItem(GlobalDefinitions.command_detonater) + cud.GUID = player.avatar.locker.GUID + sendResponse(ObjectCreateDetailedMessage( + 0L, + ObjectClass.avatar, + pguid, + vehicleAndSeat, + newPlayer.Definition.Packet.DetailedConstructorData(newPlayer).get + )) + sendResponse(ObjectCreateDetailedMessage( + 0L, + ObjectClass.command_detonater, + cud.GUID, + Some(ObjectCreateMessageParent(pguid, 4)), + cud.Definition.Packet.DetailedConstructorData(cud).get + )) + data.zoning.spawn.HandleSetCurrentAvatar(newPlayer) + data.session = session.copy(player = player) + } + + override def switchFrom(session: Session): Unit = { + import scala.concurrent.duration._ + val player = data.player + val zoning = data.zoning + val sendResponse: PlanetSidePacket => Unit = data.sendResponse + // + player.spectator = false + sendResponse(ObjectDeleteMessage(player.avatar.locker.GUID, 0)) //free up the slot (from cud) + sendResponse(ChatMsg(ChatMessageType.CMT_TOGGLESPECTATORMODE, "off")) + sendResponse(ChatMsg(ChatMessageType.UNK_227, "@SpectatorDisabled")) + zoning.spawn.randomRespawn(0.seconds) //to sanctuary + } + + def parse(sender: ActorRef): Receive = { + /* really common messages (very frequently, every life) */ + case packet: PlanetSideGamePacket => + handleGamePkt(packet) + + case AvatarServiceResponse(toChannel, guid, reply) => + avatarResponse.handle(toChannel, guid, reply) + + case GalaxyServiceResponse(_, reply) => + galaxy.handle(reply) + + case LocalServiceResponse(toChannel, guid, reply) => + local.handle(toChannel, guid, reply) + + case Mountable.MountMessages(tplayer, reply) => + mountResponse.handle(tplayer, reply) + + case SquadServiceResponse(_, excluded, response) => + squad.handle(response, excluded) + + case Terminal.TerminalMessage(tplayer, msg, order) => + terminals.handle(tplayer, msg, order) + + case VehicleServiceResponse(toChannel, guid, reply) => + vehicleResponse.handle(toChannel, guid, reply) + + case SessionActor.SendResponse(packet) => + data.sendResponse(packet) + + case SessionActor.CharSaved => () + + case SessionActor.CharSavedMsg => () + + /* common messages (maybe once every respawn) */ + case ICS.SpawnPointResponse(response) => + data.zoning.handleSpawnPointResponse(response) + + case SessionActor.NewPlayerLoaded(tplayer) => + data.zoning.spawn.handleNewPlayerLoaded(tplayer) + + case SessionActor.PlayerLoaded(tplayer) => + data.zoning.spawn.handlePlayerLoaded(tplayer) + + case Zone.Population.PlayerHasLeft(zone, None) => + data.log.debug(s"PlayerHasLeft: ${data.player.Name} does not have a body on ${zone.id}") + + case Zone.Population.PlayerHasLeft(zone, Some(tplayer)) => + if (tplayer.isAlive) { + data.log.info(s"${tplayer.Name} has left zone ${zone.id}") + } + + case Zone.Population.PlayerCanNotSpawn(zone, tplayer) => + data.log.warn(s"${tplayer.Name} can not spawn in zone ${zone.id}; why?") + + case Zone.Population.PlayerAlreadySpawned(zone, tplayer) => + data.log.warn(s"${tplayer.Name} is already spawned on zone ${zone.id}; is this a clerical error?") + + case Zone.Vehicle.CanNotSpawn(zone, vehicle, reason) => + data.log.warn( + s"${data.player.Name}'s ${vehicle.Definition.Name} can not spawn in ${zone.id} because $reason" + ) + + case Zone.Vehicle.CanNotDespawn(zone, vehicle, reason) => + data.log.warn( + s"${data.player.Name}'s ${vehicle.Definition.Name} can not deconstruct in ${zone.id} because $reason" + ) + + case ICS.ZoneResponse(Some(zone)) => + data.zoning.handleZoneResponse(zone) + + /* uncommon messages (once a session) */ + case ICS.ZonesResponse(zones) => + data.zoning.handleZonesResponse(zones) + + case SessionActor.SetAvatar(avatar) => + general.handleSetAvatar(avatar) + + case PlayerToken.LoginInfo(name, Zone.Nowhere, _) => + data.zoning.spawn.handleLoginInfoNowhere(name, sender) + + case PlayerToken.LoginInfo(name, inZone, optionalSavedData) => + data.zoning.spawn.handleLoginInfoSomewhere(name, inZone, optionalSavedData, sender) + + case PlayerToken.RestoreInfo(playerName, inZone, pos) => + data.zoning.spawn.handleLoginInfoRestore(playerName, inZone, pos, sender) + + case PlayerToken.CanNotLogin(playerName, reason) => + data.zoning.spawn.handleLoginCanNot(playerName, reason) + + case ReceiveAccountData(account) => + general.handleReceiveAccountData(account) + + case AvatarActor.AvatarResponse(avatar) => + general.handleAvatarResponse(avatar) + + case AvatarActor.AvatarLoginResponse(avatar) => + data.zoning.spawn.avatarLoginResponse(avatar) + + case SessionActor.SetCurrentAvatar(tplayer, max_attempts, attempt) => + data.zoning.spawn.ReadyToSetCurrentAvatar(tplayer, max_attempts, attempt) + + case SessionActor.SetConnectionState(state) => + data.connectionState = state + + case SessionActor.AvatarLoadingSync(state) => + data.zoning.spawn.handleAvatarLoadingSync(state) + + /* uncommon messages (utility, or once in a while) */ + case ZoningOperations.AvatarAwardMessageBundle(pkts, delay) => + data.zoning.spawn.performAvatarAwardMessageDelivery(pkts, delay) + + case CommonMessages.ProgressEvent(delta, finishedAction, stepAction, tick) => + general.ops.handleProgressChange(delta, finishedAction, stepAction, tick) + + case CommonMessages.Progress(rate, finishedAction, stepAction) => + general.ops.setupProgressChange(rate, finishedAction, stepAction) + + case CavernRotationService.CavernRotationServiceKey.Listing(listings) => + listings.head ! SendCavernRotationUpdates(data.context.self) + + case LookupResult("propertyOverrideManager", endpoint) => + data.zoning.propertyOverrideManagerLoadOverrides(endpoint) + + case SessionActor.UpdateIgnoredPlayers(msg) => + galaxy.handleUpdateIgnoredPlayers(msg) + + case SessionActor.UseCooldownRenewed(definition, _) => + general.handleUseCooldownRenew(definition) + + case Deployment.CanDeploy(obj, state) => + vehicles.handleCanDeploy(obj, state) + + case Deployment.CanUndeploy(obj, state) => + vehicles.handleCanUndeploy(obj, state) + + case Deployment.CanNotChangeDeployment(obj, state, reason) => + vehicles.handleCanNotChangeDeployment(obj, state, reason) + + /* rare messages */ + case ProximityUnit.StopAction(term, _) => + terminals.ops.LocalStopUsingProximityUnit(term) + + case SessionActor.Suicide() => + general.ops.suicide(data.player) + + case SessionActor.Recall() => + data.zoning.handleRecall() + + case SessionActor.InstantAction() => + data.zoning.handleInstantAction() + + case SessionActor.Quit() => + data.zoning.handleQuit() + + case ICS.DroppodLaunchDenial(errorCode, _) => + data.zoning.handleDroppodLaunchDenial(errorCode) + + case ICS.DroppodLaunchConfirmation(zone, position) => + data.zoning.LoadZoneLaunchDroppod(zone, position) + + case SessionActor.PlayerFailedToLoad(tplayer) => + data.failWithError(s"${tplayer.Name} failed to load anywhere") + + /* csr only */ + case SessionActor.SetSpeed(speed) => + general.handleSetSpeed(speed) + + case SessionActor.SetFlying(isFlying) => + general.handleSetFlying(isFlying) + + case SessionActor.SetSpectator(isSpectator) => + general.handleSetSpectator(isSpectator) + + case SessionActor.Kick(player, time) => + general.handleKick(player, time) + + case SessionActor.SetZone(zoneId, position) => + data.zoning.handleSetZone(zoneId, position) + + case SessionActor.SetPosition(position) => + data.zoning.spawn.handleSetPosition(position) + + case SessionActor.SetSilenced(silenced) => + general.handleSilenced(silenced) + + /* catch these messages */ + case _: ProximityUnit.Action => ; + + case _: Zone.Vehicle.HasSpawned => ; + + case _: Zone.Vehicle.HasDespawned => ; + + case Zone.Deployable.IsDismissed(obj: TurretDeployable) => //only if target deployable was never fully introduced + TaskWorkflow.execute(GUIDTask.unregisterDeployableTurret(data.continent.GUID, obj)) + + case Zone.Deployable.IsDismissed(obj) => //only if target deployable was never fully introduced + TaskWorkflow.execute(GUIDTask.unregisterObject(data.continent.GUID, obj)) + + case msg: Containable.ItemPutInSlot => + data.log.debug(s"ItemPutInSlot: $msg") + + case msg: Containable.CanNotPutItemInSlot => + data.log.debug(s"CanNotPutItemInSlot: $msg") + + case _ => () + } + + private def handleGamePkt: PlanetSideGamePacket => Unit = { + case packet: ConnectToWorldRequestMessage => + general.handleConnectToWorldRequest(packet) + + case packet: MountVehicleCargoMsg => + mountResponse.handleMountVehicleCargo(packet) + + case packet: DismountVehicleCargoMsg => + mountResponse.handleDismountVehicleCargo(packet) + + case packet: CharacterCreateRequestMessage => + general.handleCharacterCreateRequest(packet) + + case packet: CharacterRequestMessage => + general.handleCharacterRequest(packet) + + case _: KeepAliveMessage => + data.keepAliveFunc() + + case packet: BeginZoningMessage => + data.zoning.handleBeginZoning(packet) + + case packet: PlayerStateMessageUpstream => + general.handlePlayerStateUpstream(packet) + + case packet: ChildObjectStateMessage => + vehicles.handleChildObjectState(packet) + + case packet: VehicleStateMessage => + vehicles.handleVehicleState(packet) + + case packet: VehicleSubStateMessage => + vehicles.handleVehicleSubState(packet) + + case packet: FrameVehicleStateMessage => + vehicles.handleFrameVehicleState(packet) + + case packet: ProjectileStateMessage => + shooting.handleProjectileState(packet) + + case packet: LongRangeProjectileInfoMessage => + shooting.handleLongRangeProjectileState(packet) + + case packet: ReleaseAvatarRequestMessage => + data.zoning.spawn.handleReleaseAvatarRequest(packet) + + case packet: SpawnRequestMessage => + data.zoning.spawn.handleSpawnRequest(packet) + + case packet: ChatMsg => + general.handleChat(packet) + + case packet: SetChatFilterMessage => + general.handleChatFilter(packet) + + case packet: VoiceHostRequest => + general.handleVoiceHostRequest(packet) + + case packet: VoiceHostInfo => + general.handleVoiceHostInfo(packet) + + case packet: ChangeAmmoMessage => + shooting.handleChangeAmmo(packet) + + case packet: ChangeFireModeMessage => + shooting.handleChangeFireMode(packet) + + case packet: ChangeFireStateMessage_Start => + shooting.handleChangeFireStateStart(packet) + + case packet: ChangeFireStateMessage_Stop => + shooting.handleChangeFireStateStop(packet) + + case packet: EmoteMsg => + general.handleEmote(packet) + + case packet: DropItemMessage => + general.handleDropItem(packet) + + case packet: PickupItemMessage => + general.handlePickupItem(packet) + + case packet: ReloadMessage => + shooting.handleReload(packet) + + case packet: ObjectHeldMessage => + general.handleObjectHeld(packet) + + case packet: AvatarJumpMessage => + general.handleAvatarJump(packet) + + case packet: ZipLineMessage => + general.handleZipLine(packet) + + case packet: RequestDestroyMessage => + general.handleRequestDestroy(packet) + + case packet: MoveItemMessage => + general.handleMoveItem(packet) + + case packet: LootItemMessage => + general.handleLootItem(packet) + + case packet: AvatarImplantMessage => + general.handleAvatarImplant(packet) + + case packet: UseItemMessage => + general.handleUseItem(packet) + + case packet: UnuseItemMessage => + general.handleUnuseItem(packet) + + case packet: ProximityTerminalUseMessage => + terminals.handleProximityTerminalUse(packet) + + case packet: DeployObjectMessage => + general.handleDeployObject(packet) + + case packet: GenericObjectActionMessage => + general.handleGenericObjectAction(packet) + + case packet: GenericObjectActionAtPositionMessage => + general.handleGenericObjectActionAtPosition(packet) + + case packet: GenericObjectStateMsg => + general.handleGenericObjectState(packet) + + case packet: GenericActionMessage => + general.handleGenericAction(packet) + + case packet: ItemTransactionMessage => + terminals.handleItemTransaction(packet) + + case packet: FavoritesRequest => + terminals.handleFavoritesRequest(packet) + + case packet: WeaponDelayFireMessage => + shooting.handleWeaponDelayFire(packet) + + case packet: WeaponDryFireMessage => + shooting.handleWeaponDryFire(packet) + + case packet: WeaponFireMessage => + shooting.handleWeaponFire(packet) + + case packet: WeaponLazeTargetPositionMessage => + shooting.handleWeaponLazeTargetPosition(packet) + + case packet: UplinkRequest => + shooting.handleUplinkRequest(packet) + + case packet: HitMessage => + shooting.handleDirectHit(packet) + + case packet: SplashHitMessage => + shooting.handleSplashHit(packet) + + case packet: LashMessage => + shooting.handleLashHit(packet) + + case packet: AIDamage => + shooting.handleAIDamage(packet) + + case packet: AvatarFirstTimeEventMessage => + general.handleAvatarFirstTimeEvent(packet) + + case packet: WarpgateRequest => + data.zoning.handleWarpgateRequest(packet) + + case packet: MountVehicleMsg => + mountResponse.handleMountVehicle(packet) + + case packet: DismountVehicleMsg => + mountResponse.handleDismountVehicle(packet) + + case packet: DeployRequestMessage => + vehicles.handleDeployRequest(packet) + + case packet: AvatarGrenadeStateMessage => + shooting.handleAvatarGrenadeState(packet) + + case packet: SquadDefinitionActionMessage => + squad.handleSquadDefinitionAction(packet) + + case packet: SquadMembershipRequest => + squad.handleSquadMemberRequest(packet) + + case packet: SquadWaypointRequest => + squad.handleSquadWaypointRequest(packet) + + case packet: GenericCollisionMsg => + general.handleGenericCollision(packet) + + case packet: BugReportMessage => + general.handleBugReport(packet) + + case packet: BindPlayerMessage => + general.handleBindPlayer(packet) + + case packet: PlanetsideAttributeMessage => + general.handlePlanetsideAttribute(packet) + + case packet: FacilityBenefitShieldChargeRequestMessage => + general.handleFacilityBenefitShieldChargeRequest(packet) + + case packet: BattleplanMessage => + general.handleBattleplan(packet) + + case packet: CreateShortcutMessage => + general.handleCreateShortcut(packet) + + case packet: ChangeShortcutBankMessage => + general.handleChangeShortcutBank(packet) + + case packet: FriendsRequest => + general.handleFriendRequest(packet) + + case packet: DroppodLaunchRequestMessage => + data.zoning.handleDroppodLaunchRequest(packet) + + case packet: InvalidTerrainMessage => + general.handleInvalidTerrain(packet) + + case packet: ActionCancelMessage => + general.handleActionCancel(packet) + + case packet: TradeMessage => + general.handleTrade(packet) + + case packet: DisplayedAwardMessage => + general.handleDisplayedAward(packet) + + case packet: ObjectDetectedMessage => + general.handleObjectDetected(packet) + + case packet: TargetingImplantRequest => + general.handleTargetingImplantRequest(packet) + + case packet: HitHint => + general.handleHitHint(packet) + + case _: OutfitRequest => () + + case pkt => + data.log.warn(s"Unhandled GamePacket $pkt") + } +} + +object SpectatorModeLogic { + final val SpectatorImplants: Seq[Option[Implant]] = Seq( + Some(Implant(GlobalDefinitions.targeting, initialized = true)), + Some(Implant(GlobalDefinitions.darklight_vision, initialized = true)), + Some(Implant(GlobalDefinitions.range_magnifier, initialized = true)) + ) + + private def spectatorCharacter(player: Player): Player = { + val avatar = player.avatar + val newAvatar = avatar.copy( + basic = avatar.basic.copy(name = "spectator"), + bep = BattleRank.BR18.experience, + cep = CommandRank.CR5.experience, + certifications = Set(), + decoration = ProgressDecoration( + ribbonBars = RibbonBars( + MeritCommendation.BendingMovieActor, + MeritCommendation.BendingMovieActor, + MeritCommendation.BendingMovieActor, + MeritCommendation.BendingMovieActor + ), + firstTimeEvents = FirstTimeEvents.All + ), + deployables = { + val dt = new DeployableToolbox() + dt.Initialize(Set()) + dt + }, + implants = SpectatorImplants, + lookingForSquad = false, + shortcuts = { + val allShortcuts: Array[Option[AvatarShortcut]] = Array.fill[Option[AvatarShortcut]](64)(None) + SpectatorImplants.zipWithIndex.collect { case (Some(implant), slot) => + allShortcuts.update(slot + 1, Some(AvatarShortcut(2, implant.definition.Name))) + } + allShortcuts + } + ) + val newPlayer = Player(newAvatar) + newPlayer.GUID = player.GUID + newPlayer.ExoSuit = ExoSuitType.Infiltration + newPlayer.Position = player.Position + newPlayer.Orientation = player.Orientation + newPlayer.spectator = true + newPlayer.Spawn() + newPlayer + } +} + +case object SpectatorMode extends PlayerMode { + def setup(data: SessionData): ModeLogic = { + new SpectatorModeLogic(data) + } +} diff --git a/src/main/scala/net/psforever/actors/session/spectator/SquadHandlerLogic.scala b/src/main/scala/net/psforever/actors/session/spectator/SquadHandlerLogic.scala new file mode 100644 index 000000000..fae35e07e --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/spectator/SquadHandlerLogic.scala @@ -0,0 +1,184 @@ +// Copyright (c) 2024 PSForever +package net.psforever.actors.session.spectator + +import akka.actor.{ActorContext, typed} +import net.psforever.actors.session.support.SessionSquadHandlers.SquadUIElement +import net.psforever.actors.session.{AvatarActor, ChatActor} +import net.psforever.actors.session.support.{SessionData, SessionSquadHandlers, SquadHandlerFunctions} +import net.psforever.objects.{Default, LivePlayerList} +import net.psforever.objects.avatar.Avatar +import net.psforever.packet.game.{CharacterKnowledgeInfo, CharacterKnowledgeMessage, PlanetsideAttributeMessage, ReplicationStreamMessage, SquadAction, SquadDefinitionActionMessage, SquadDetailDefinitionUpdateMessage, SquadListing, SquadMemberEvent, SquadMembershipRequest, SquadMembershipResponse, SquadState, SquadStateInfo, SquadWaypointEvent, SquadWaypointRequest, WaypointEventAction} +import net.psforever.services.chat.ChatService +import net.psforever.services.teamwork.SquadResponse +import net.psforever.types.{PlanetSideGUID, SquadListDecoration, SquadResponseType} + +object SquadHandlerLogic { + def apply(ops: SessionSquadHandlers): SquadHandlerLogic = { + new SquadHandlerLogic(ops, ops.context) + } +} + +class SquadHandlerLogic(val ops: SessionSquadHandlers, implicit val context: ActorContext) extends SquadHandlerFunctions { + def sessionLogic: SessionData = ops.sessionLogic + + private val avatarActor: typed.ActorRef[AvatarActor.Command] = ops.avatarActor + + private val chatActor: typed.ActorRef[ChatActor.Command] = ops.chatActor + + //private val squadService: ActorRef = ops.squadService + + /* packet */ + + def handleSquadDefinitionAction(pkt: SquadDefinitionActionMessage): Unit = { /* intentionally blank */ } + + def handleSquadMemberRequest(pkt: SquadMembershipRequest): Unit = { /* intentionally blank */ } + + def handleSquadWaypointRequest(pkt: SquadWaypointRequest): Unit = { /* intentionally blank */ } + + /* response handlers */ + + def handle(response: SquadResponse.Response, excluded: Iterable[Long]): Unit = { + if (!excluded.exists(_ == avatar.id)) { + response match { + case SquadResponse.InitList(infos) => + sendResponse(ReplicationStreamMessage(infos)) + + case SquadResponse.UpdateList(infos) if infos.nonEmpty => + sendResponse( + ReplicationStreamMessage( + 6, + None, + infos.map { + case (index, squadInfo) => + SquadListing(index, squadInfo) + }.toVector + ) + ) + + case SquadResponse.RemoveFromList(infos) if infos.nonEmpty => + sendResponse( + ReplicationStreamMessage( + 1, + None, + infos.map { index => + SquadListing(index, None) + }.toVector + ) + ) + + case SquadResponse.SquadDecoration(guid, squad) => + val decoration = if ( + ops.squadUI.nonEmpty || + squad.Size == squad.Capacity || + { + val offer = avatar.certifications + !squad.Membership.exists { _.isAvailable(offer) } + } + ) { + SquadListDecoration.NotAvailable + } else { + SquadListDecoration.Available + } + sendResponse(SquadDefinitionActionMessage(guid, 0, SquadAction.SquadListDecorator(decoration))) + + case SquadResponse.Detail(guid, detail) => + sendResponse(SquadDetailDefinitionUpdateMessage(guid, detail)) + + case SquadResponse.Membership(request_type, unk1, unk2, charId, opt_char_id, player_name, unk5, unk6) => + val name = request_type match { + case SquadResponseType.Invite if unk5 => + //the name of the player indicated by unk3 is needed + LivePlayerList.WorldPopulation({ case (_, a: Avatar) => charId == a.id }).headOption match { + case Some(player) => + player.name + case None => + player_name + } + case _ => + player_name + } + sendResponse(SquadMembershipResponse(request_type, unk1, unk2, charId, opt_char_id, name, unk5, unk6)) + + case SquadResponse.Leave(squad, positionsToUpdate) => + positionsToUpdate.find({ case (member, _) => member == avatar.id }) match { + case Some((ourMember, ourIndex)) => + //we are leaving the squad + //remove each member's entry (our own too) + ops.updateSquadRef = Default.Actor + positionsToUpdate.foreach { + case (member, index) => + sendResponse(SquadMemberEvent.Remove(ops.squad_supplement_id, member, index)) + ops.squadUI.remove(member) + } + //uninitialize + val playerGuid = player.GUID + sendResponse(SquadMemberEvent.Remove(ops.squad_supplement_id, ourMember, ourIndex)) //repeat of our entry + ops.GiveSquadColorsToSelf(value = 0) + sendResponse(PlanetsideAttributeMessage(playerGuid, 32, 0)) //disassociate with member position in squad? + sendResponse(PlanetsideAttributeMessage(playerGuid, 34, 4294967295L)) //unknown, perhaps unrelated? + avatarActor ! AvatarActor.SetLookingForSquad(false) + //a finalization? what does this do? + sendResponse(SquadDefinitionActionMessage(PlanetSideGUID(0), 0, SquadAction.Unknown(18))) + ops.squad_supplement_id = 0 + ops.squadUpdateCounter = 0 + ops.updateSquad = ops.NoSquadUpdates + chatActor ! ChatActor.LeaveChannel(ChatService.ChatChannel.Squad(squad.GUID)) + case _ => + //remove each member's entry + ops.GiveSquadColorsToMembers( + positionsToUpdate.map { + case (member, index) => + sendResponse(SquadMemberEvent.Remove(ops.squad_supplement_id, member, index)) + ops.squadUI.remove(member) + member + }, + value = 0 + ) + } + + case SquadResponse.UpdateMembers(_, positions) => + val pairedEntries = positions.collect { + case entry if ops.squadUI.contains(entry.char_id) => + (entry, ops.squadUI(entry.char_id)) + } + //prune entries + val updatedEntries = pairedEntries + .collect({ + case (entry, element) if entry.zone_number != element.zone => + //zone gets updated for these entries + sendResponse( + SquadMemberEvent.UpdateZone(ops.squad_supplement_id, entry.char_id, element.index, entry.zone_number) + ) + ops.squadUI(entry.char_id) = + SquadUIElement(element.name, element.outfit, element.index, entry.zone_number, entry.health, entry.armor, entry.pos) + entry + case (entry, element) + if entry.health != element.health || entry.armor != element.armor || entry.pos != element.position => + //other elements that need to be updated + ops.squadUI(entry.char_id) = + SquadUIElement(element.name, element.outfit, element.index, entry.zone_number, entry.health, entry.armor, entry.pos) + entry + }) + .filterNot(_.char_id == avatar.id) //we want to update our backend, but not our frontend + if (updatedEntries.nonEmpty) { + sendResponse( + SquadState( + PlanetSideGUID(ops.squad_supplement_id), + updatedEntries.map { entry => + SquadStateInfo(entry.char_id, entry.health, entry.armor, entry.pos) + } + ) + ) + } + + case SquadResponse.CharacterKnowledge(charId, name, certs, u1, u2, zone) => + sendResponse(CharacterKnowledgeMessage(charId, Some(CharacterKnowledgeInfo(name, certs, u1, u2, zone)))) + + case SquadResponse.WaypointEvent(WaypointEventAction.Remove, char_id, waypoint_type, _, _, _) => + sendResponse(SquadWaypointEvent.Remove(ops.squad_supplement_id, char_id, waypoint_type)) + + case _ => () + } + } + } +} diff --git a/src/main/scala/net/psforever/actors/session/spectator/TerminalHandlerLogic.scala b/src/main/scala/net/psforever/actors/session/spectator/TerminalHandlerLogic.scala new file mode 100644 index 000000000..2187ec4ba --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/spectator/TerminalHandlerLogic.scala @@ -0,0 +1,45 @@ +// Copyright (c) 2024 PSForever +package net.psforever.actors.session.spectator + +import akka.actor.ActorContext +import net.psforever.actors.session.support.{SessionData, SessionTerminalHandlers, TerminalHandlerFunctions} +import net.psforever.login.WorldSession.SellEquipmentFromInventory +import net.psforever.objects.Player +import net.psforever.objects.serverobject.terminals.Terminal +import net.psforever.packet.game.{FavoritesRequest, ItemTransactionMessage, ItemTransactionResultMessage, ProximityTerminalUseMessage} + +object TerminalHandlerLogic { + def apply(ops: SessionTerminalHandlers): TerminalHandlerLogic = { + new TerminalHandlerLogic(ops, ops.context) + } +} + +class TerminalHandlerLogic(val ops: SessionTerminalHandlers, implicit val context: ActorContext) extends TerminalHandlerFunctions { + def sessionLogic: SessionData = ops.sessionLogic + + def handleItemTransaction(pkt: ItemTransactionMessage): Unit = { /* intentionally blank */ } + + def handleProximityTerminalUse(pkt: ProximityTerminalUseMessage): Unit = { /* intentionally blank */ } + + def handleFavoritesRequest(pkt: FavoritesRequest): Unit = { /* intentionally blank */ } + + /** + * na + * @param tplayer na + * @param msg na + * @param order na + */ + def handle(tplayer: Player, msg: ItemTransactionMessage, order: Terminal.Exchange): Unit = { + order match { + case Terminal.SellEquipment() => + SellEquipmentFromInventory(tplayer, tplayer, msg.terminal_guid)(Player.FreeHandSlot) + + case _ if msg != null => + sendResponse(ItemTransactionResultMessage(msg.terminal_guid, msg.transaction_type, success = false)) + ops.lastTerminalOrderFulfillment = true + + case _ => + ops.lastTerminalOrderFulfillment = true + } + } +} diff --git a/src/main/scala/net/psforever/actors/session/spectator/VehicleHandlerLogic.scala b/src/main/scala/net/psforever/actors/session/spectator/VehicleHandlerLogic.scala new file mode 100644 index 000000000..ee66f69e9 --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/spectator/VehicleHandlerLogic.scala @@ -0,0 +1,399 @@ +// Copyright (c) 2024 PSForever +package net.psforever.actors.session.spectator + +import akka.actor.{ActorContext, ActorRef, typed} +import net.psforever.actors.session.AvatarActor +import net.psforever.actors.session.support.{SessionData, SessionVehicleHandlers, VehicleHandlerFunctions} +import net.psforever.objects.{GlobalDefinitions, Player, Tool, Vehicle, Vehicles} +import net.psforever.objects.equipment.{Equipment, JammableMountedWeapons, JammableUnit} +import net.psforever.objects.guid.{GUIDTask, TaskWorkflow} +import net.psforever.objects.serverobject.mount.Mountable +import net.psforever.objects.serverobject.pad.VehicleSpawnPad +import net.psforever.packet.game.objectcreate.ObjectCreateMessageParent +import net.psforever.packet.game.{ChangeAmmoMessage, ChangeFireStateMessage_Start, ChangeFireStateMessage_Stop, ChatMsg, ChildObjectStateMessage, DeadState, DeployRequestMessage, DismountVehicleMsg, FrameVehicleStateMessage, GenericObjectActionMessage, HitHint, InventoryStateMessage, ObjectAttachMessage, ObjectCreateDetailedMessage, ObjectCreateMessage, ObjectDeleteMessage, ObjectDetachMessage, PlanetsideAttributeMessage, ReloadMessage, ServerVehicleOverrideMsg, VehicleStateMessage, WeaponDryFireMessage} +import net.psforever.services.Service +import net.psforever.services.vehicle.{VehicleResponse, VehicleServiceResponse} +import net.psforever.types.{BailType, ChatMessageType, PlanetSideGUID, Vector3} + +object VehicleHandlerLogic { + def apply(ops: SessionVehicleHandlers): VehicleHandlerLogic = { + new VehicleHandlerLogic(ops, ops.context) + } +} + +class VehicleHandlerLogic(val ops: SessionVehicleHandlers, implicit val context: ActorContext) extends VehicleHandlerFunctions { + def sessionLogic: SessionData = ops.sessionLogic + + private val avatarActor: typed.ActorRef[AvatarActor.Command] = ops.avatarActor + + private val galaxyService: ActorRef = ops.galaxyService + + /** + * na + * + * @param toChannel na + * @param guid na + * @param reply na + */ + def handle(toChannel: String, guid: PlanetSideGUID, reply: VehicleResponse.Response): Unit = { + val resolvedPlayerGuid = if (player.HasGUID) { + player.GUID + } else { + PlanetSideGUID(-1) + } + val isNotSameTarget = resolvedPlayerGuid != guid + reply match { + case VehicleResponse.VehicleState( + vehicleGuid, + unk1, + pos, + orient, + vel, + unk2, + unk3, + unk4, + wheelDirection, + unk5, + unk6 + ) if isNotSameTarget && player.VehicleSeated.contains(vehicleGuid) => + //player who is also in the vehicle (not driver) + sendResponse(VehicleStateMessage(vehicleGuid, unk1, pos, orient, vel, unk2, unk3, unk4, wheelDirection, unk5, unk6)) + player.Position = pos + player.Orientation = orient + player.Velocity = vel + sessionLogic.updateLocalBlockMap(pos) + + case VehicleResponse.VehicleState( + vehicleGuid, + unk1, + pos, + ang, + vel, + unk2, + unk3, + unk4, + wheelDirection, + unk5, + unk6 + ) if isNotSameTarget => + //player who is watching the vehicle from the outside + sendResponse(VehicleStateMessage(vehicleGuid, unk1, pos, ang, vel, unk2, unk3, unk4, wheelDirection, unk5, unk6)) + + case VehicleResponse.ChildObjectState(objectGuid, pitch, yaw) if isNotSameTarget => + sendResponse(ChildObjectStateMessage(objectGuid, pitch, yaw)) + + case VehicleResponse.FrameVehicleState(vguid, u1, pos, oient, vel, u2, u3, u4, is_crouched, u6, u7, u8, u9, uA) + if isNotSameTarget => + sendResponse(FrameVehicleStateMessage(vguid, u1, pos, oient, vel, u2, u3, u4, is_crouched, u6, u7, u8, u9, uA)) + + case VehicleResponse.ChangeFireState_Start(weaponGuid) if isNotSameTarget => + sendResponse(ChangeFireStateMessage_Start(weaponGuid)) + + case VehicleResponse.ChangeFireState_Stop(weaponGuid) if isNotSameTarget => + sendResponse(ChangeFireStateMessage_Stop(weaponGuid)) + + case VehicleResponse.Reload(itemGuid) if isNotSameTarget => + sendResponse(ReloadMessage(itemGuid, ammo_clip=1, unk1=0)) + + case VehicleResponse.ChangeAmmo(weapon_guid, weapon_slot, previous_guid, ammo_id, ammo_guid, ammo_data) if isNotSameTarget => + sendResponse(ObjectDetachMessage(weapon_guid, previous_guid, Vector3.Zero, 0)) + //TODO? sendResponse(ObjectDeleteMessage(previousAmmoGuid, 0)) + sendResponse( + ObjectCreateMessage( + ammo_id, + ammo_guid, + ObjectCreateMessageParent(weapon_guid, weapon_slot), + ammo_data + ) + ) + sendResponse(ChangeAmmoMessage(weapon_guid, 1)) + + case VehicleResponse.WeaponDryFire(weaponGuid) if isNotSameTarget => + continent.GUID(weaponGuid).collect { + case tool: Tool if tool.Magazine == 0 => + // check that the magazine is still empty before sending WeaponDryFireMessage + // if it has been reloaded since then, other clients will not see it firing + sendResponse(WeaponDryFireMessage(weaponGuid)) + } + + case VehicleResponse.DismountVehicle(bailType, wasKickedByDriver) if isNotSameTarget => + sendResponse(DismountVehicleMsg(guid, bailType, wasKickedByDriver)) + + case VehicleResponse.MountVehicle(vehicleGuid, seat) if isNotSameTarget => + sendResponse(ObjectAttachMessage(vehicleGuid, guid, seat)) + + case VehicleResponse.DeployRequest(objectGuid, state, unk1, unk2, pos) if isNotSameTarget => + sendResponse(DeployRequestMessage(guid, objectGuid, state, unk1, unk2, pos)) + + case VehicleResponse.SendResponse(msg) => + sendResponse(msg) + + case VehicleResponse.AttachToRails(vehicleGuid, padGuid) => + sendResponse(ObjectAttachMessage(padGuid, vehicleGuid, slot=3)) + + case VehicleResponse.ConcealPlayer(playerGuid) => + sendResponse(GenericObjectActionMessage(playerGuid, code=9)) + + case VehicleResponse.DetachFromRails(vehicleGuid, padGuid, padPosition, padOrientationZ) => + val pad = continent.GUID(padGuid).get.asInstanceOf[VehicleSpawnPad].Definition + sendResponse( + ObjectDetachMessage( + padGuid, + vehicleGuid, + padPosition + Vector3.z(pad.VehicleCreationZOffset), + padOrientationZ + pad.VehicleCreationZOrientOffset + ) + ) + + case VehicleResponse.EquipmentInSlot(pkt) if isNotSameTarget => + sendResponse(pkt) + + case VehicleResponse.GenericObjectAction(objectGuid, action) if isNotSameTarget => + sendResponse(GenericObjectActionMessage(objectGuid, action)) + + case VehicleResponse.HitHint(sourceGuid) if player.isAlive => + sendResponse(HitHint(sourceGuid, player.GUID)) + + case VehicleResponse.InventoryState(obj, parentGuid, start, conData) if isNotSameTarget => + //TODO prefer ObjectDetachMessage, but how to force ammo pools to update properly? + val objGuid = obj.GUID + sendResponse(ObjectDeleteMessage(objGuid, unk1=0)) + sendResponse(ObjectCreateDetailedMessage( + obj.Definition.ObjectId, + objGuid, + ObjectCreateMessageParent(parentGuid, start), + conData + )) + + case VehicleResponse.KickPassenger(_, wasKickedByDriver, vehicleGuid) if resolvedPlayerGuid == guid => + //seat number (first field) seems to be correct if passenger is kicked manually by driver + //but always seems to return 4 if user is kicked by mount permissions changing + sendResponse(DismountVehicleMsg(guid, BailType.Kicked, wasKickedByDriver)) + val typeOfRide = continent.GUID(vehicleGuid) match { + case Some(obj: Vehicle) => + sessionLogic.general.unaccessContainer(obj) + s"the ${obj.Definition.Name}'s seat by ${obj.OwnerName.getOrElse("the pilot")}" + case _ => + s"${player.Sex.possessive} ride" + } + log.info(s"${player.Name} has been kicked from $typeOfRide!") + + case VehicleResponse.KickPassenger(_, wasKickedByDriver, _) => + //seat number (first field) seems to be correct if passenger is kicked manually by driver + //but always seems to return 4 if user is kicked by mount permissions changing + sendResponse(DismountVehicleMsg(guid, BailType.Kicked, wasKickedByDriver)) + + case VehicleResponse.InventoryState2(objGuid, parentGuid, value) if isNotSameTarget => + sendResponse(InventoryStateMessage(objGuid, unk=0, parentGuid, value)) + + case VehicleResponse.LoadVehicle(vehicle, vtype, vguid, vdata) if isNotSameTarget => + //this is not be suitable for vehicles with people who are seated in it before it spawns (if that is possible) + sendResponse(ObjectCreateMessage(vtype, vguid, vdata)) + Vehicles.ReloadAccessPermissions(vehicle, player.Name) + + case VehicleResponse.ObjectDelete(itemGuid) if isNotSameTarget => + sendResponse(ObjectDeleteMessage(itemGuid, unk1=0)) + + case VehicleResponse.Ownership(vehicleGuid) if resolvedPlayerGuid == guid => + //Only the player that owns this vehicle needs the ownership packet + avatarActor ! AvatarActor.SetVehicle(Some(vehicleGuid)) + sendResponse(PlanetsideAttributeMessage(resolvedPlayerGuid, attribute_type=21, vehicleGuid)) + + case VehicleResponse.PlanetsideAttribute(vehicleGuid, attributeType, attributeValue) if isNotSameTarget => + sendResponse(PlanetsideAttributeMessage(vehicleGuid, attributeType, attributeValue)) + + case VehicleResponse.ResetSpawnPad(padGuid) => + sendResponse(GenericObjectActionMessage(padGuid, code=23)) + + case VehicleResponse.RevealPlayer(playerGuid) => + sendResponse(GenericObjectActionMessage(playerGuid, code=10)) + + case VehicleResponse.SeatPermissions(vehicleGuid, seatGroup, permission) if isNotSameTarget => + sendResponse(PlanetsideAttributeMessage(vehicleGuid, seatGroup, permission)) + + case VehicleResponse.StowEquipment(vehicleGuid, slot, itemType, itemGuid, itemData) if isNotSameTarget => + //TODO prefer ObjectAttachMessage, but how to force ammo pools to update properly? + sendResponse(ObjectCreateDetailedMessage(itemType, itemGuid, ObjectCreateMessageParent(vehicleGuid, slot), itemData)) + + case VehicleResponse.UnloadVehicle(_, vehicleGuid) => + sendResponse(ObjectDeleteMessage(vehicleGuid, unk1=0)) + + case VehicleResponse.UnstowEquipment(itemGuid) if isNotSameTarget => + //TODO prefer ObjectDetachMessage, but how to force ammo pools to update properly? + sendResponse(ObjectDeleteMessage(itemGuid, unk1=0)) + + case VehicleResponse.UpdateAmsSpawnPoint(list) => + sessionLogic.zoning.spawn.amsSpawnPoints = list.filter(tube => tube.Faction == player.Faction) + sessionLogic.zoning.spawn.DrawCurrentAmsSpawnPoint() + + case VehicleResponse.TransferPassengerChannel(oldChannel, tempChannel, vehicle, vehicleToDelete) if isNotSameTarget => + sessionLogic.zoning.interstellarFerry = Some(vehicle) + sessionLogic.zoning.interstellarFerryTopLevelGUID = Some(vehicleToDelete) + continent.VehicleEvents ! Service.Leave(Some(oldChannel)) //old vehicle-specific channel (was s"${vehicle.Actor}") + galaxyService ! Service.Join(tempChannel) //temporary vehicle-specific channel + log.debug(s"TransferPassengerChannel: ${player.Name} now subscribed to $tempChannel for vehicle gating") + + case VehicleResponse.KickCargo(vehicle, speed, delay) + if player.VehicleSeated.nonEmpty && sessionLogic.zoning.spawn.deadState == DeadState.Alive && speed > 0 => + val strafe = 1 + Vehicles.CargoOrientation(vehicle) + val reverseSpeed = if (strafe > 1) { 0 } else { speed } + //strafe or reverse, not both + sessionLogic.vehicles.ServerVehicleOverrideWithPacket( + vehicle, + ServerVehicleOverrideMsg( + lock_accelerator=true, + lock_wheel=true, + reverse=true, + unk4=false, + lock_vthrust=0, + strafe, + reverseSpeed, + unk8=Some(0) + ) + ) + import scala.concurrent.ExecutionContext.Implicits.global + import scala.concurrent.duration._ + context.system.scheduler.scheduleOnce( + delay milliseconds, + context.self, + VehicleServiceResponse(toChannel, PlanetSideGUID(0), VehicleResponse.KickCargo(vehicle, speed=0, delay)) + ) + + case VehicleResponse.KickCargo(cargo, _, _) + if player.VehicleSeated.nonEmpty && sessionLogic.zoning.spawn.deadState == DeadState.Alive => + sessionLogic.vehicles.TotalDriverVehicleControl(cargo) + + case VehicleResponse.StartPlayerSeatedInVehicle(vehicle, _) + if player.VisibleSlots.contains(player.DrawnSlot) => + player.DrawnSlot = Player.HandsDownSlot + startPlayerSeatedInVehicle(vehicle) + + case VehicleResponse.StartPlayerSeatedInVehicle(vehicle, _) => + startPlayerSeatedInVehicle(vehicle) + + case VehicleResponse.PlayerSeatedInVehicle(vehicle, _) => + Vehicles.ReloadAccessPermissions(vehicle, player.Name) + sessionLogic.vehicles.ServerVehicleOverrideWithPacket( + vehicle, + ServerVehicleOverrideMsg( + lock_accelerator=true, + lock_wheel=true, + reverse=true, + unk4=false, + lock_vthrust=1, + lock_strafe=0, + movement_speed=0, + unk8=Some(0) + ) + ) + sessionLogic.vehicles.serverVehicleControlVelocity = Some(0) + + case VehicleResponse.ServerVehicleOverrideStart(vehicle, _) => + val vdef = vehicle.Definition + sessionLogic.vehicles.ServerVehicleOverrideWithPacket( + vehicle, + ServerVehicleOverrideMsg( + lock_accelerator=true, + lock_wheel=true, + reverse=false, + unk4=false, + lock_vthrust=if (GlobalDefinitions.isFlightVehicle(vdef)) { 1 } else { 0 }, + lock_strafe=0, + movement_speed=vdef.AutoPilotSpeed1, + unk8=Some(0) + ) + ) + + case VehicleResponse.ServerVehicleOverrideEnd(vehicle, _) => + sessionLogic.vehicles.ServerVehicleOverrideStop(vehicle) + + case VehicleResponse.PeriodicReminder(VehicleSpawnPad.Reminders.Blocked, data) => + sendResponse(ChatMsg( + ChatMessageType.CMT_OPEN, + wideContents=true, + recipient="", + s"The vehicle spawn where you placed your order is blocked. ${data.getOrElse("")}", + note=None + )) + + case VehicleResponse.PeriodicReminder(_, data) => + val (isType, flag, msg): (ChatMessageType, Boolean, String) = data match { + case Some(msg: String) if msg.startsWith("@") => (ChatMessageType.UNK_227, false, msg) + case Some(msg: String) => (ChatMessageType.CMT_OPEN, true, msg) + case _ => (ChatMessageType.CMT_OPEN, true, "Your vehicle order has been cancelled.") + } + sendResponse(ChatMsg(isType, flag, recipient="", msg, None)) + + case VehicleResponse.ChangeLoadout(target, oldWeapons, addedWeapons, oldInventory, newInventory) + if player.avatar.vehicle.contains(target) => + //TODO when vehicle weapons can be changed without visual glitches, rewrite this + continent.GUID(target).collect { case vehicle: Vehicle => + import net.psforever.login.WorldSession.boolToInt + //owner: must unregister old equipment, and register and install new equipment + (oldWeapons ++ oldInventory).foreach { + case (obj, eguid) => + sendResponse(ObjectDeleteMessage(eguid, unk1=0)) + TaskWorkflow.execute(GUIDTask.unregisterEquipment(continent.GUID, obj)) + } + sessionLogic.general.applyPurchaseTimersBeforePackingLoadout(player, vehicle, addedWeapons ++ newInventory) + //jammer or unjamm new weapons based on vehicle status + val vehicleJammered = vehicle.Jammed + addedWeapons + .map { _.obj } + .collect { + case jamItem: JammableUnit if jamItem.Jammed != vehicleJammered => + jamItem.Jammed = vehicleJammered + JammableMountedWeapons.JammedWeaponStatus(vehicle.Zone, jamItem, vehicleJammered) + } + changeLoadoutDeleteOldEquipment(vehicle, oldWeapons, oldInventory) + } + + case VehicleResponse.ChangeLoadout(target, oldWeapons, _, oldInventory, _) + if sessionLogic.general.accessedContainer.map(_.GUID).contains(target) => + //TODO when vehicle weapons can be changed without visual glitches, rewrite this + continent.GUID(target).collect { case vehicle: Vehicle => + //external participant: observe changes to equipment + (oldWeapons ++ oldInventory).foreach { case (_, eguid) => sendResponse(ObjectDeleteMessage(eguid, unk1=0)) } + changeLoadoutDeleteOldEquipment(vehicle, oldWeapons, oldInventory) + } + + case VehicleResponse.ChangeLoadout(target, oldWeapons, _, oldInventory, _) => + //TODO when vehicle weapons can be changed without visual glitches, rewrite this + continent.GUID(target).collect { case vehicle: Vehicle => + changeLoadoutDeleteOldEquipment(vehicle, oldWeapons, oldInventory) + } + + case _ => () + } + } + + private def changeLoadoutDeleteOldEquipment( + vehicle: Vehicle, + oldWeapons: Iterable[(Equipment, PlanetSideGUID)], + oldInventory: Iterable[(Equipment, PlanetSideGUID)] + ): Unit = { + vehicle.PassengerInSeat(player) match { + case Some(seatNum) => + //participant: observe changes to equipment + (oldWeapons ++ oldInventory).foreach { + case (_, eguid) => sendResponse(ObjectDeleteMessage(eguid, unk1=0)) + } + sessionLogic.mountResponse.updateWeaponAtSeatPosition(vehicle, seatNum) + case None => + //observer: observe changes to external equipment + oldWeapons.foreach { case (_, eguid) => sendResponse(ObjectDeleteMessage(eguid, unk1=0)) } + } + } + + private def startPlayerSeatedInVehicle(vehicle: Vehicle): Unit = { + val vehicle_guid = vehicle.GUID + sessionLogic.actionsToCancel() + sessionLogic.terminals.CancelAllProximityUnits() + sessionLogic.vehicles.serverVehicleControlVelocity = Some(0) + sendResponse(PlanetsideAttributeMessage(vehicle_guid, attribute_type=22, attribute_value=1L)) //mount points off + sendResponse(PlanetsideAttributeMessage(player.GUID, attribute_type=21, vehicle_guid)) //ownership + vehicle.MountPoints.find { case (_, mp) => mp.seatIndex == 0 }.collect { + case (mountPoint, _) => vehicle.Actor ! Mountable.TryMount(player, mountPoint) + } + } +} diff --git a/src/main/scala/net/psforever/actors/session/spectator/VehicleLogic.scala b/src/main/scala/net/psforever/actors/session/spectator/VehicleLogic.scala new file mode 100644 index 000000000..355c1fdc5 --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/spectator/VehicleLogic.scala @@ -0,0 +1,363 @@ +// Copyright (c) 2024 PSForever +package net.psforever.actors.session.spectator + +import akka.actor.{ActorContext, typed} +import net.psforever.actors.session.AvatarActor +import net.psforever.actors.session.support.{SessionData, VehicleFunctions, VehicleOperations} +import net.psforever.objects.serverobject.PlanetSideServerObject +import net.psforever.objects.{PlanetSideGameObject, Player, Vehicle, Vehicles} +import net.psforever.objects.serverobject.deploy.Deployment +import net.psforever.objects.serverobject.mount.Mountable +import net.psforever.objects.vehicles.control.BfrFlight +import net.psforever.objects.zones.Zone +import net.psforever.packet.game.{ChildObjectStateMessage, DeployRequestMessage, FrameVehicleStateMessage, VehicleStateMessage, VehicleSubStateMessage} +import net.psforever.services.vehicle.{VehicleAction, VehicleServiceMessage} +import net.psforever.types.{DriveState, Vector3} + +object VehicleLogic { + def apply(ops: VehicleOperations): VehicleLogic = { + new VehicleLogic(ops, ops.context) + } +} + +class VehicleLogic(val ops: VehicleOperations, implicit val context: ActorContext) extends VehicleFunctions { + def sessionLogic: SessionData = ops.sessionLogic + + private val avatarActor: typed.ActorRef[AvatarActor.Command] = ops.avatarActor + + /* packets */ + + def handleVehicleState(pkt: VehicleStateMessage): Unit = { + val VehicleStateMessage( + vehicle_guid, + unk1, + pos, + ang, + vel, + is_flying, + unk6, + unk7, + wheels, + is_decelerating, + is_cloaked + ) = pkt + GetVehicleAndSeat() match { + case (Some(obj), Some(0)) => + //we're driving the vehicle + sessionLogic.persist() + sessionLogic.turnCounterFunc(player.GUID) + sessionLogic.general.fallHeightTracker(pos.z) + if (obj.MountedIn.isEmpty) { + sessionLogic.updateBlockMap(obj, pos) + } + player.Position = pos //convenient + if (obj.WeaponControlledFromSeat(0).isEmpty) { + player.Orientation = Vector3.z(ang.z) //convenient + } + obj.Position = pos + obj.Orientation = ang + if (obj.MountedIn.isEmpty) { + if (obj.DeploymentState != DriveState.Deployed) { + obj.Velocity = vel + } else { + obj.Velocity = Some(Vector3.Zero) + } + if (obj.Definition.CanFly) { + obj.Flying = is_flying //usually Some(7) + } + obj.Cloaked = obj.Definition.CanCloak && is_cloaked + } else { + obj.Velocity = None + obj.Flying = None + } + continent.VehicleEvents ! VehicleServiceMessage( + continent.id, + VehicleAction.VehicleState( + player.GUID, + vehicle_guid, + unk1, + obj.Position, + ang, + obj.Velocity, + if (obj.isFlying) { + is_flying + } else { + None + }, + unk6, + unk7, + wheels, + is_decelerating, + obj.Cloaked + ) + ) + sessionLogic.squad.updateSquad() + obj.zoneInteractions() + case (None, _) => + //log.error(s"VehicleState: no vehicle $vehicle_guid found in zone") + //TODO placing a "not driving" warning here may trigger as we are disembarking the vehicle + case (_, Some(index)) => + log.error( + s"VehicleState: ${player.Name} should not be dispatching this kind of packet from vehicle ${vehicle_guid.guid} when not the driver (actually, seat $index)" + ) + case _ => ; + } + if (player.death_by == -1) { + sessionLogic.kickedByAdministration() + } + } + + def handleFrameVehicleState(pkt: FrameVehicleStateMessage): Unit = { + val FrameVehicleStateMessage( + vehicle_guid, + unk1, + pos, + ang, + vel, + unk2, + unk3, + unk4, + is_crouched, + is_airborne, + ascending_flight, + flight_time, + unk9, + unkA + ) = pkt + GetVehicleAndSeat() match { + case (Some(obj), Some(0)) => + //we're driving the vehicle + sessionLogic.persist() + sessionLogic.turnCounterFunc(player.GUID) + val (position, angle, velocity, notMountedState) = continent.GUID(obj.MountedIn) match { + case Some(v: Vehicle) => + sessionLogic.updateBlockMap(obj, pos) + (pos, v.Orientation - Vector3.z(value = 90f) * Vehicles.CargoOrientation(obj).toFloat, v.Velocity, false) + case _ => + (pos, ang, vel, true) + } + player.Position = position //convenient + if (obj.WeaponControlledFromSeat(seatNumber = 0).isEmpty) { + player.Orientation = Vector3.z(ang.z) //convenient + } + obj.Position = position + obj.Orientation = angle + obj.Velocity = velocity + // if (is_crouched && obj.DeploymentState != DriveState.Kneeling) { + // //dev stuff goes here + // } + // else + // if (!is_crouched && obj.DeploymentState == DriveState.Kneeling) { + // //dev stuff goes here + // } + obj.DeploymentState = if (is_crouched || !notMountedState) DriveState.Kneeling else DriveState.Mobile + if (notMountedState) { + if (obj.DeploymentState != DriveState.Kneeling) { + if (is_airborne) { + val flight = if (ascending_flight) flight_time else -flight_time + obj.Flying = Some(flight) + obj.Actor ! BfrFlight.Soaring(flight) + } else if (obj.Flying.nonEmpty) { + obj.Flying = None + obj.Actor ! BfrFlight.Landed + } + } else { + obj.Velocity = None + obj.Flying = None + } + obj.zoneInteractions() + } else { + obj.Velocity = None + obj.Flying = None + } + continent.VehicleEvents ! VehicleServiceMessage( + continent.id, + VehicleAction.FrameVehicleState( + player.GUID, + vehicle_guid, + unk1, + position, + angle, + velocity, + unk2, + unk3, + unk4, + is_crouched, + is_airborne, + ascending_flight, + flight_time, + unk9, + unkA + ) + ) + sessionLogic.squad.updateSquad() + case (None, _) => + //log.error(s"VehicleState: no vehicle $vehicle_guid found in zone") + //TODO placing a "not driving" warning here may trigger as we are disembarking the vehicle + case (_, Some(index)) => + log.error( + s"VehicleState: ${player.Name} should not be dispatching this kind of packet from vehicle ${vehicle_guid.guid} when not the driver (actually, seat $index)" + ) + case _ => () + } + if (player.death_by == -1) { + sessionLogic.kickedByAdministration() + } + } + + def handleChildObjectState(pkt: ChildObjectStateMessage): Unit = { + val ChildObjectStateMessage(object_guid, pitch, yaw) = pkt + val (o, tools) = sessionLogic.shooting.FindContainedWeapon + //is COSM our primary upstream packet? + (o match { + case Some(mount: Mountable) => (o, mount.PassengerInSeat(player)) + case _ => (None, None) + }) match { + case (None, None) | (_, None) | (Some(_: Vehicle), Some(0)) => () + case _ => + sessionLogic.persist() + sessionLogic.turnCounterFunc(player.GUID) + } + //the majority of the following check retrieves information to determine if we are in control of the child + tools.find { _.GUID == object_guid } match { + case None => () + case Some(_) => player.Orientation = Vector3(0f, pitch, yaw) + } + if (player.death_by == -1) { + sessionLogic.kickedByAdministration() + } + } + + def handleVehicleSubState(pkt: VehicleSubStateMessage): Unit = { + val VehicleSubStateMessage(vehicle_guid, _, pos, ang, vel, unk1, _) = pkt + sessionLogic.validObject(vehicle_guid, decorator = "VehicleSubState") match { + case Some(obj: Vehicle) => + import net.psforever.login.WorldSession.boolToInt + obj.Position = pos + obj.Orientation = ang + obj.Velocity = vel + sessionLogic.updateBlockMap(obj, pos) + obj.zoneInteractions() + continent.VehicleEvents ! VehicleServiceMessage( + continent.id, + VehicleAction.VehicleState( + player.GUID, + vehicle_guid, + unk1, + pos, + ang, + obj.Velocity, + obj.Flying, + 0, + 0, + 15, + unk5 = false, + obj.Cloaked + ) + ) + case _ => () + } + } + + def handleDeployRequest(pkt: DeployRequestMessage): Unit = { + val DeployRequestMessage(_, vehicle_guid, deploy_state, _, _, _) = pkt + val vehicle = player.avatar.vehicle + if (vehicle.contains(vehicle_guid)) { + if (vehicle == player.VehicleSeated) { + continent.GUID(vehicle_guid) match { + case Some(obj: Vehicle) => + if (obj.DeploymentState == DriveState.Deployed) { + obj.Actor ! Deployment.TryDeploymentChange(deploy_state) + } + case _ => () + avatarActor ! AvatarActor.SetVehicle(None) + } + } + } + } + + /* messages */ + + def handleCanDeploy(obj: Deployment.DeploymentObject, state: DriveState.Value): Unit = { /* intentionally blank */ } + + def handleCanUndeploy(obj: Deployment.DeploymentObject, state: DriveState.Value): Unit = { + if (state != DriveState.Undeploying && state != DriveState.Mobile) { + CanNotChangeDeployment(obj, state, "incorrect undeploy state") + } + } + + def handleCanNotChangeDeployment(obj: Deployment.DeploymentObject, state: DriveState.Value, reason: String): Unit = { + if (Deployment.CheckForDeployState(state) && !Deployment.AngleCheck(obj)) { + CanNotChangeDeployment(obj, state, reason = "ground too steep") + } else { + CanNotChangeDeployment(obj, state, reason) + } + } + + /* support functions */ + + /** + * If the player is mounted in some entity, find that entity and get the mount index number at which the player is sat. + * The priority of object confirmation is `direct` then `occupant.VehicleSeated`. + * Once an object is found, the remainder are ignored. + * @param direct a game object in which the player may be sat + * @param occupant the player who is sat and may have specified the game object in which mounted + * @return a tuple consisting of a vehicle reference and a mount index + * if and only if the vehicle is known to this client and the `WorldSessioNActor`-global `player` occupies it; + * `(None, None)`, otherwise (even if the vehicle can be determined) + */ + private def GetMountableAndSeat( + direct: Option[PlanetSideGameObject with Mountable], + occupant: Player, + zone: Zone + ): (Option[PlanetSideGameObject with Mountable], Option[Int]) = + direct.orElse(zone.GUID(occupant.VehicleSeated)) match { + case Some(obj: PlanetSideGameObject with Mountable) => + obj.PassengerInSeat(occupant) match { + case index @ Some(_) => + (Some(obj), index) + case None => + (None, None) + } + case _ => + (None, None) + } + + /** + * If the player is seated in a vehicle, find that vehicle and get the mount index number at which the player is sat. + * @see `GetMountableAndSeat` + * @return a tuple consisting of a vehicle reference and a mount index + * if and only if the vehicle is known to this client and the `WorldSessioNActor`-global `player` occupies it; + * `(None, None)`, otherwise (even if the vehicle can be determined) + */ + private def GetVehicleAndSeat(): (Option[Vehicle], Option[Int]) = + GetMountableAndSeat(None, player, continent) match { + case (Some(v: Vehicle), Some(seat)) => (Some(v), Some(seat)) + case _ => (None, None) + } + + /** + * Common reporting behavior when a `Deployment` object fails to properly transition between states. + * @param obj the game object that could not + * @param state the `DriveState` that could not be promoted + * @param reason a string explaining why the state can not or will not change + */ + private def CanNotChangeDeployment( + obj: PlanetSideServerObject with Deployment, + state: DriveState.Value, + reason: String + ): Unit = { + val mobileShift: String = if (obj.DeploymentState != DriveState.Mobile) { + obj.DeploymentState = DriveState.Mobile + sendResponse(DeployRequestMessage(player.GUID, obj.GUID, DriveState.Mobile, 0, unk3=false, Vector3.Zero)) + continent.VehicleEvents ! VehicleServiceMessage( + continent.id, + VehicleAction.DeployRequest(player.GUID, obj.GUID, DriveState.Mobile, 0, unk2=false, Vector3.Zero) + ) + "; enforcing Mobile deployment state" + } else { + "" + } + log.error(s"DeployRequest: ${player.Name} can not transition $obj to $state - $reason$mobileShift") + } +} diff --git a/src/main/scala/net/psforever/actors/session/spectator/WeaponAndProjectileLogic.scala b/src/main/scala/net/psforever/actors/session/spectator/WeaponAndProjectileLogic.scala new file mode 100644 index 000000000..eaf8492d5 --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/spectator/WeaponAndProjectileLogic.scala @@ -0,0 +1,680 @@ +// Copyright (c) 2024 PSForever +package net.psforever.actors.session.spectator + +import akka.actor.{ActorContext, typed} +import net.psforever.actors.session.AvatarActor +import net.psforever.actors.session.support.{SessionData, WeaponAndProjectileFunctions, WeaponAndProjectileOperations} +import net.psforever.login.WorldSession.{CountGrenades, FindEquipmentStock, FindToolThatUses, RemoveOldEquipmentFromInventory} +import net.psforever.objects.ballistics.{Projectile, ProjectileQuality} +import net.psforever.objects.definition.ProjectileDefinition +import net.psforever.objects.equipment.{ChargeFireModeDefinition, EquipmentSize} +import net.psforever.objects.inventory.Container +import net.psforever.objects.serverobject.affinity.FactionAffinity +import net.psforever.objects.serverobject.{CommonMessages, PlanetSideServerObject} +import net.psforever.objects.serverobject.doors.InteriorDoorPassage +import net.psforever.objects.{AmmoBox, BoomerDeployable, BoomerTrigger, DummyExplodingEntity, GlobalDefinitions, OwnableByPlayer, PlanetSideGameObject, SpecialEmp, Tool} +import net.psforever.objects.serverobject.interior.Sidedness +import net.psforever.objects.serverobject.mount.Mountable +import net.psforever.objects.serverobject.turret.auto.{AutomatedTurret, AutomatedTurretBehavior} +import net.psforever.objects.sourcing.SourceEntry +import net.psforever.objects.vital.Vitality +import net.psforever.objects.vital.base.{DamageResolution, DamageType} +import net.psforever.objects.vital.etc.OicwLilBuddyReason +import net.psforever.objects.vital.interaction.DamageInteraction +import net.psforever.objects.vital.projectile.ProjectileReason +import net.psforever.objects.zones.{Zone, ZoneProjectile} +import net.psforever.packet.game.{AIDamage, AvatarGrenadeStateMessage, ChainLashMessage, ChangeAmmoMessage, ChangeFireModeMessage, ChangeFireStateMessage_Start, ChangeFireStateMessage_Stop, HitMessage, InventoryStateMessage, LashMessage, LongRangeProjectileInfoMessage, ProjectileStateMessage, QuantityUpdateMessage, ReloadMessage, SplashHitMessage, UplinkRequest, UplinkRequestType, UplinkResponse, WeaponDelayFireMessage, WeaponDryFireMessage, WeaponFireMessage, WeaponLazeTargetPositionMessage} +import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage} +import net.psforever.types.{PlanetSideGUID, Vector3} +import net.psforever.util.Config + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.duration._ + +object WeaponAndProjectileLogic { + def apply(ops: WeaponAndProjectileOperations): WeaponAndProjectileLogic = { + new WeaponAndProjectileLogic(ops, ops.context) + } + + /** + * Does a line segment line intersect with a sphere?
+ * This most likely belongs in `Geometry` or `GeometryForm` or somehow in association with the `\objects\geometry\` package. + * @param start first point of the line segment + * @param end second point of the line segment + * @param center center of the sphere + * @param radius radius of the sphere + * @return list of all points of intersection, if any + * @see `Vector3.DistanceSquared` + * @see `Vector3.MagnitudeSquared` + */ + private def quickLineSphereIntersectionPoints( + start: Vector3, + end: Vector3, + center: Vector3, + radius: Float + ): Iterable[Vector3] = { + /* + Algorithm adapted from code found on https://paulbourke.net/geometry/circlesphere/index.html#linesphere, + because I kept messing up proper substitution of the line formula and the circle formula into the quadratic equation. + */ + val Vector3(cx, cy, cz) = center + val Vector3(sx, sy, sz) = start + val vector = end - start + //speed our way through a quadratic equation + val (a, b) = { + val Vector3(dx, dy, dz) = vector + ( + dx * dx + dy * dy + dz * dz, + 2f * (dx * (sx - cx) + dy * (sy - cy) + dz * (sz - cz)) + ) + } + val c = Vector3.MagnitudeSquared(center) + Vector3.MagnitudeSquared(start) - 2f * (cx * sx + cy * sy + cz * sz) - radius * radius + val result = b * b - 4 * a * c + if (result < 0f) { + //negative, no intersection + Seq() + } else if (result < 0.00001f) { + //zero-ish, one intersection point + Seq(start - vector * (b / (2f * a))) + } else { + //positive, two intersection points + val sqrt = math.sqrt(result).toFloat + val endStart = vector / (2f * a) + Seq(start + endStart * (sqrt - b), start + endStart * (b + sqrt) * -1f) + }.filter(p => Vector3.DistanceSquared(start, p) <= a) + } + /** + * Preparation for explosion damage that utilizes the Scorpion's little buddy sub-projectiles. + * The main difference from "normal" server-side explosion + * is that the owner of the projectile must be clarified explicitly. + * @see `Zone::serverSideDamage` + * @param zone where the explosion is taking place + * (`source` contains the coordinate location) + * @param source a game object that represents the source of the explosion + * @param owner who or what to accredit damage from the explosion to; + * clarifies a normal `SourceEntry(source)` accreditation + */ + private def detonateLittleBuddy( + zone: Zone, + source: PlanetSideGameObject with FactionAffinity with Vitality, + proxy: Projectile, + owner: SourceEntry + )(): Unit = { + Zone.serverSideDamage(zone, source, littleBuddyExplosionDamage(owner, proxy.id, source.Position)) + } + + /** + * Preparation for explosion damage that utilizes the Scorpion's little buddy sub-projectiles. + * The main difference from "normal" server-side explosion + * is that the owner of the projectile must be clarified explicitly. + * The sub-projectiles will be the product of a normal projectile rather than a standard game object + * so a custom `source` entity must wrap around it and fulfill the requirements of the field. + * @see `Zone::explosionDamage` + * @param owner who or what to accredit damage from the explosion to + * @param explosionPosition where the explosion will be positioned in the game world + * @param source a game object that represents the source of the explosion + * @param target a game object that is affected by the explosion + * @return a `DamageInteraction` object + */ + private def littleBuddyExplosionDamage( + owner: SourceEntry, + projectileId: Long, + explosionPosition: Vector3 + ) + ( + source: PlanetSideGameObject with FactionAffinity with Vitality, + target: PlanetSideGameObject with FactionAffinity with Vitality + ): DamageInteraction = { + DamageInteraction(SourceEntry(target), OicwLilBuddyReason(owner, projectileId, target.DamageModel), explosionPosition) + } +} + +class WeaponAndProjectileLogic(val ops: WeaponAndProjectileOperations, implicit val context: ActorContext) extends WeaponAndProjectileFunctions { + def sessionLogic: SessionData = ops.sessionLogic + + private val avatarActor: typed.ActorRef[AvatarActor.Command] = ops.avatarActor + + /* packets */ + + def handleWeaponFire(pkt: WeaponFireMessage): Unit = { /* intentionally blank */ } + + def handleWeaponDelayFire(pkt: WeaponDelayFireMessage): Unit = { /* intentionally blank */ } + + def handleWeaponDryFire(pkt: WeaponDryFireMessage): Unit = { /* intentionally blank */ } + + def handleWeaponLazeTargetPosition(pkt: WeaponLazeTargetPositionMessage): Unit = { /* intentionally blank */ } + + def handleUplinkRequest(packet: UplinkRequest): Unit = { + val UplinkRequest(code, _, _) = packet + val playerFaction = player.Faction + code match { + case UplinkRequestType.RevealFriendlies => + sendResponse(UplinkResponse(code.value, continent.LivePlayers.count(_.Faction == playerFaction))) + case UplinkRequestType.RevealEnemies => + sendResponse(UplinkResponse(code.value, continent.LivePlayers.count(_.Faction != playerFaction))) + case _ => () + } + } + + def handleAvatarGrenadeState(pkt: AvatarGrenadeStateMessage): Unit = { /* intentionally blank */ } + + def handleChangeFireStateStart(pkt: ChangeFireStateMessage_Start): Unit = { /* intentionally blank */ } + + def handleChangeFireStateStop(pkt: ChangeFireStateMessage_Stop): Unit = { + val ChangeFireStateMessage_Stop(item_guid) = pkt + val now = System.currentTimeMillis() + ops.prefire -= item_guid + ops.shootingStop += item_guid -> now + ops.shooting -= item_guid + sessionLogic.findEquipment(item_guid) match { + case Some(tool: Tool) if player.VehicleSeated.isEmpty => + fireStateStopWhenPlayer(tool, item_guid) + case Some(tool: Tool) => + fireStateStopWhenMounted(tool, item_guid) + case Some(trigger: BoomerTrigger) => + ops.fireStateStopPlayerMessages(item_guid) + continent.GUID(trigger.Companion).collect { + case boomer: BoomerDeployable => + boomer.Actor ! CommonMessages.Use(player, Some(trigger)) + } + case Some(_) if player.VehicleSeated.isEmpty => + ops.fireStateStopPlayerMessages(item_guid) + case Some(_) => + ops.fireStateStopMountedMessages(item_guid) + case _ => () + } + sessionLogic.general.progressBarUpdate.cancel() + sessionLogic.general.progressBarValue = None + } + + def handleReload(pkt: ReloadMessage): Unit = { /* intentionally blank */ } + + def handleChangeAmmo(pkt: ChangeAmmoMessage): Unit = { /* intentionally blank */ } + + def handleChangeFireMode(pkt: ChangeFireModeMessage): Unit = { /* intentionally blank */ } + + def handleProjectileState(pkt: ProjectileStateMessage): Unit = { + val ProjectileStateMessage(projectile_guid, shot_pos, shot_vel, shot_orient, seq, end, target_guid) = pkt + val index = projectile_guid.guid - Projectile.baseUID + ops.projectiles(index) match { + case Some(projectile) if projectile.HasGUID => + val projectileGlobalUID = projectile.GUID + projectile.Position = shot_pos + projectile.Orientation = shot_orient + projectile.Velocity = shot_vel + continent.AvatarEvents ! AvatarServiceMessage( + continent.id, + AvatarAction.ProjectileState( + player.GUID, + projectileGlobalUID, + shot_pos, + shot_vel, + shot_orient, + seq, + end, + target_guid + ) + ) + case _ if seq == 0 => + /* missing the first packet in the sequence is permissible */ + case _ => + log.warn(s"ProjectileState: constructed projectile ${projectile_guid.guid} can not be found") + } + } + + def handleLongRangeProjectileState(pkt: LongRangeProjectileInfoMessage): Unit = { /* intentionally blank */ } + + def handleDirectHit(pkt: HitMessage): Unit = { /* intentionally blank */ } + + def handleSplashHit(pkt: SplashHitMessage): Unit = { + val SplashHitMessage( + _, + projectile_guid, + explosion_pos, + direct_victim_uid, + _, + projectile_vel, + _, + targets + ) = pkt + ops.FindProjectileEntry(projectile_guid) match { + case Some(projectile) => + val profile = projectile.profile + projectile.Velocity = projectile_vel + val (resolution1, resolution2) = profile.Aggravated match { + case Some(_) if profile.ProjectileDamageTypes.contains(DamageType.Aggravated) => + (DamageResolution.AggravatedDirect, DamageResolution.AggravatedSplash) + case _ => + (DamageResolution.Splash, DamageResolution.Splash) + } + //direct_victim_uid + sessionLogic.validObject(direct_victim_uid, decorator = "SplashHit/direct_victim") match { + case Some(target: PlanetSideGameObject with FactionAffinity with Vitality) => + CheckForHitPositionDiscrepancy(projectile_guid, explosion_pos, target) + ResolveProjectileInteraction(projectile, resolution1, target, target.Position).collect { resprojectile => + addShotsLanded(resprojectile.cause.attribution, shots = 1) + sessionLogic.handleDealingDamage(target, resprojectile) + } + case _ => () + } + //other victims + targets.foreach(elem => { + sessionLogic.validObject(elem.uid, decorator = "SplashHit/other_victims") match { + case Some(target: PlanetSideGameObject with FactionAffinity with Vitality) => + CheckForHitPositionDiscrepancy(projectile_guid, explosion_pos, target) + ResolveProjectileInteraction(projectile, resolution2, target, explosion_pos).collect { resprojectile => + addShotsLanded(resprojectile.cause.attribution, shots = 1) + sessionLogic.handleDealingDamage(target, resprojectile) + } + case _ => () + } + }) + //... + HandleDamageProxy(projectile, projectile_guid, explosion_pos) + if ( + projectile.profile.HasJammedEffectDuration || + projectile.profile.JammerProjectile || + projectile.profile.SympatheticExplosion + ) { + //can also substitute 'projectile.profile' for 'SpecialEmp.emp' + Zone.serverSideDamage( + continent, + player, + SpecialEmp.emp, + SpecialEmp.createEmpInteraction(SpecialEmp.emp, explosion_pos), + SpecialEmp.prepareDistanceCheck(player, explosion_pos, player.Faction), + SpecialEmp.findAllBoomers(profile.DamageRadius) + ) + } + if (profile.ExistsOnRemoteClients && projectile.HasGUID) { + //cleanup + if (projectile.HasGUID) { + continent.Projectile ! ZoneProjectile.Remove(projectile.GUID) + } + } + case None => () + } + } + + def handleLashHit(pkt: LashMessage): Unit = { /* intentionally blank */ } + + def handleAIDamage(pkt: AIDamage): Unit = { + val AIDamage(targetGuid, attackerGuid, projectileTypeId, _, _) = pkt + (continent.GUID(player.VehicleSeated) match { + case Some(tobj: PlanetSideServerObject with FactionAffinity with Vitality with OwnableByPlayer) + if tobj.GUID == targetGuid && + tobj.OwnerGuid.contains(player.GUID) => + //deployable turrets + Some(tobj) + case Some(tobj: PlanetSideServerObject with FactionAffinity with Vitality with Mountable) + if tobj.GUID == targetGuid && + tobj.Seats.values.flatMap(_.occupants.map(_.GUID)).toSeq.contains(player.GUID) => + //facility turrets, etc. + Some(tobj) + case _ + if player.GUID == targetGuid => + //player avatars + Some(player) + case _ => + None + }).collect { + case target: AutomatedTurret.Target => + sessionLogic.validObject(attackerGuid, decorator = "AIDamage/AutomatedTurret") + .collect { + case turret: AutomatedTurret if turret.Target.isEmpty => + turret.Actor ! AutomatedTurretBehavior.ConfirmShot(target) + Some(target) + + case turret: AutomatedTurret => + turret.Actor ! AutomatedTurretBehavior.ConfirmShot(target) + HandleAIDamage(target, CompileAutomatedTurretDamageData(turret, turret.TurretOwner, projectileTypeId)) + Some(target) + } + } + .orElse { + //occasionally, something that is not technically a turret's natural target may be attacked + sessionLogic.validObject(targetGuid, decorator = "AIDamage/Target") + .collect { + case target: PlanetSideServerObject with FactionAffinity with Vitality => + sessionLogic.validObject(attackerGuid, decorator = "AIDamage/Attacker") + .collect { + case turret: AutomatedTurret if turret.Target.nonEmpty => + //the turret must be shooting at something (else) first + HandleAIDamage(target, CompileAutomatedTurretDamageData(turret, turret.TurretOwner, projectileTypeId)) + } + Some(target) + } + } + } + + /* support code */ + + /** + * After a weapon has finished shooting, determine if it needs to be sorted in a special way. + * @param tool a weapon + */ + private def FireCycleCleanup(tool: Tool): Unit = { + //TODO replaced by more appropriate functionality in the future + val tdef = tool.Definition + if (GlobalDefinitions.isGrenade(tdef)) { + val ammoType = tool.AmmoType + FindEquipmentStock(player, FindToolThatUses(ammoType), 3, CountGrenades).reverse match { //do not search sidearm holsters + case Nil => + log.info(s"${player.Name} has no more $ammoType grenades to throw") + RemoveOldEquipmentFromInventory(player)(tool) + + case x :: xs => //this is similar to ReloadMessage + val box = x.obj.asInstanceOf[Tool] + val tailReloadValue: Int = if (xs.isEmpty) { 0 } + else { xs.map(_.obj.asInstanceOf[Tool].Magazine).sum } + val sumReloadValue: Int = box.Magazine + tailReloadValue + val actualReloadValue = if (sumReloadValue <= 3) { + RemoveOldEquipmentFromInventory(player)(x.obj) + sumReloadValue + } else { + ModifyAmmunition(player)(box.AmmoSlot.Box, 3 - tailReloadValue) + 3 + } + log.info(s"${player.Name} found $actualReloadValue more $ammoType grenades to throw") + ModifyAmmunition(player)( + tool.AmmoSlot.Box, + -actualReloadValue + ) //grenade item already in holster (negative because empty) + xs.foreach(item => { RemoveOldEquipmentFromInventory(player)(item.obj) }) + } + } else if (tdef == GlobalDefinitions.phoenix) { + RemoveOldEquipmentFromInventory(player)(tool) + } + } + + /** + * Given an object that contains a box of amunition in its `Inventory` at a certain location, + * change the amount of ammunition within that box. + * @param obj the `Container` + * @param box an `AmmoBox` to modify + * @param reloadValue the value to modify the `AmmoBox`; + * subtracted from the current `Capacity` of `Box` + */ + private def ModifyAmmunition(obj: PlanetSideGameObject with Container)(box: AmmoBox, reloadValue: Int): Unit = { + val capacity = box.Capacity - reloadValue + box.Capacity = capacity + sendResponse(InventoryStateMessage(box.GUID, obj.GUID, capacity)) + } + + private def CheckForHitPositionDiscrepancy( + projectile_guid: PlanetSideGUID, + hitPos: Vector3, + target: PlanetSideGameObject with FactionAffinity with Vitality + ): Unit = { + val hitPositionDiscrepancy = Vector3.DistanceSquared(hitPos, target.Position) + if (hitPositionDiscrepancy > Config.app.antiCheat.hitPositionDiscrepancyThreshold) { + // If the target position on the server does not match the position where the projectile landed within reason there may be foul play + log.warn( + s"${player.Name}'s shot #${projectile_guid.guid} has hit discrepancy with target. Target: ${target.Position}, Reported: $hitPos, Distance: $hitPositionDiscrepancy / ${math.sqrt(hitPositionDiscrepancy).toFloat}; suspect" + ) + } + } + + /** + * na + * @param projectile the projectile object + * @param resolution the resolution status to promote the projectile + * @return a copy of the projectile + */ + private def ResolveProjectileInteraction( + projectile: Projectile, + resolution: DamageResolution.Value, + target: PlanetSideGameObject with FactionAffinity with Vitality, + pos: Vector3 + ): Option[DamageInteraction] = { + if (projectile.isMiss) { + log.warn("expected projectile was already counted as a missed shot; can not resolve any further") + None + } else { + val outProjectile = ProjectileQuality.modifiers(projectile, resolution, target, pos, Some(player)) + if (projectile.tool_def.Size == EquipmentSize.Melee && outProjectile.quality == ProjectileQuality.Modified(25)) { + avatarActor ! AvatarActor.ConsumeStamina(10) + } + Some(DamageInteraction(SourceEntry(target), ProjectileReason(resolution, outProjectile, target.DamageModel), pos)) + } + } + + /** + * Take a projectile that was introduced into the game world and + * determine if it generates a secondary damage projectile or + * an method of damage causation that requires additional management. + * @param projectile the projectile + * @param pguid the client-local projectile identifier + * @param hitPos the game world position where the projectile is being recorded + * @return a for all affected targets, a combination of projectiles, projectile location, and the target's location; + * nothing if no targets were affected + */ + private def HandleDamageProxy( + projectile: Projectile, + pguid: PlanetSideGUID, + hitPos: Vector3 + ): List[(PlanetSideGameObject with FactionAffinity with Vitality, Projectile, Vector3, Vector3)] = { + GlobalDefinitions.getDamageProxy(projectile, hitPos) match { + case Nil => + Nil + case list if list.isEmpty => + Nil + case list => + HandleDamageProxySetupLittleBuddy(list, hitPos) + UpdateProjectileSidednessAfterHit(projectile, hitPos) + val projectileSide = projectile.WhichSide + list.flatMap { proxy => + if (proxy.profile.ExistsOnRemoteClients) { + proxy.Position = hitPos + proxy.WhichSide = projectileSide + continent.Projectile ! ZoneProjectile.Add(player.GUID, proxy) + Nil + } else if (proxy.tool_def == GlobalDefinitions.maelstrom) { + //server-side maelstrom grenade target selection + val radius = proxy.profile.LashRadius * proxy.profile.LashRadius + val targets = Zone.findAllTargets(continent, hitPos, proxy.profile.LashRadius, { _.livePlayerList }) + .filter { target => + Vector3.DistanceSquared(target.Position, hitPos) <= radius + } + //chainlash is separated from the actual damage application for convenience + continent.AvatarEvents ! AvatarServiceMessage( + continent.id, + AvatarAction.SendResponse( + PlanetSideGUID(0), + ChainLashMessage( + hitPos, + projectile.profile.ObjectId, + targets.map { _.GUID } + ) + ) + ) + targets.map { target => + CheckForHitPositionDiscrepancy(pguid, hitPos, target) + (target, proxy, hitPos, target.Position) + } + } else { + Nil + } + } + } + } + + private def HandleDamageProxySetupLittleBuddy(listOfProjectiles: List[Projectile], detonationPosition: Vector3): Boolean = { + val listOfLittleBuddies: List[Projectile] = listOfProjectiles.filter { _.tool_def == GlobalDefinitions.oicw } + val size: Int = listOfLittleBuddies.size + if (size > 0) { + val desiredDownwardsProjectiles: Int = 2 + val firstHalf: Int = math.min(size, desiredDownwardsProjectiles) //number that fly straight down + val secondHalf: Int = math.max(size - firstHalf, 0) //number that are flared out + val z: Float = player.Orientation.z //player's standing direction + val north: Vector3 = Vector3(0,1,0) //map North + val speed: Float = 144f //speed (packet discovered) + val dist: Float = 25 //distance (client defined) + val downwardsAngle: Float = -85f + val flaredAngle: Float = -70f + //angle of separation for downwards, degrees from vertical for flared out + val (smallStep, smallAngle): (Float, Float) = if (firstHalf > 1) { + (360f / firstHalf, downwardsAngle) + } else { + (0f, 0f) + } + val (largeStep, largeAngle): (Float, Float) = if (secondHalf > 1) { + (360f / secondHalf, flaredAngle) + } else { + (0f, 0f) + } + val smallRotOffset: Float = z + 90f + val largeRotOffset: Float = z + math.random().toFloat * 45f + val verticalCorrection = Vector3.z(dist - dist * math.sin(math.toRadians(90 - smallAngle + largeAngle)).toFloat) + //downwards projectiles + var i: Int = 0 + listOfLittleBuddies.take(firstHalf).foreach { proxy => + val facing = (smallRotOffset + smallStep * i.toFloat) % 360 + val dir = north.Rx(smallAngle).Rz(facing) + proxy.Position = detonationPosition + dir.xy + verticalCorrection + proxy.Velocity = dir * speed + proxy.Orientation = Vector3(0, (360f + smallAngle) % 360, facing) + HandleDamageProxyLittleBuddyExplosion(proxy, dir, dist) + i += 1 + } + //flared out projectiles + i = 0 + listOfLittleBuddies.drop(firstHalf).foreach { proxy => + val facing = (largeRotOffset + largeStep * i.toFloat) % 360 + val dir = north.Rx(largeAngle).Rz(facing) + proxy.Position = detonationPosition + dir + proxy.Velocity = dir * speed + proxy.Orientation = Vector3(0, (360f + largeAngle) % 360, facing) + HandleDamageProxyLittleBuddyExplosion(proxy, dir, dist) + i += 1 + } + true + } else { + false + } + } + + private def HandleDamageProxyLittleBuddyExplosion(proxy: Projectile, orientation: Vector3, distance: Float): Unit = { + //explosion + val obj = new DummyExplodingEntity(proxy, proxy.owner.Faction) + obj.Position = obj.Position + orientation * distance + val explosionFunc: ()=>Unit = WeaponAndProjectileLogic.detonateLittleBuddy(continent, obj, proxy, proxy.owner) + context.system.scheduler.scheduleOnce(500.milliseconds) { explosionFunc() } + } + + private def fireStateStartPlayerMessages(itemGuid: PlanetSideGUID): Unit = { + continent.AvatarEvents ! AvatarServiceMessage( + continent.id, + AvatarAction.ChangeFireState_Start(player.GUID, itemGuid) + ) + } + + /* + used by ChangeFireStateMessage_Stop handling + */ + private def fireStateStopUpdateChargeAndCleanup(tool: Tool): Unit = { + tool.FireMode match { + case _: ChargeFireModeDefinition => + sendResponse(QuantityUpdateMessage(tool.AmmoSlot.Box.GUID, tool.Magazine)) + case _ => () + } + if (tool.Magazine == 0) { + FireCycleCleanup(tool) + } + } + + private def fireStateStopWhenPlayer(tool: Tool, itemGuid: PlanetSideGUID): Unit = { + //the decimator does not send a ChangeFireState_Start on the last shot; heaven knows why + //suppress the decimator's alternate fire mode, however + if ( + tool.Definition == GlobalDefinitions.phoenix && + tool.Projectile != GlobalDefinitions.phoenix_missile_guided_projectile + ) { + fireStateStartPlayerMessages(itemGuid) + } + fireStateStopUpdateChargeAndCleanup(tool) + ops.fireStateStopPlayerMessages(itemGuid) + } + + private def fireStateStopWhenMounted(tool: Tool, itemGuid: PlanetSideGUID): Unit = { + fireStateStopUpdateChargeAndCleanup(tool) + ops.fireStateStopMountedMessages(itemGuid) + } + + //noinspection SameParameterValue + private def addShotsLanded(weaponId: Int, shots: Int): Unit = { + ops.addShotsToMap(ops.shotsLanded, weaponId, shots) + } + + private def CompileAutomatedTurretDamageData( + turret: AutomatedTurret, + owner: SourceEntry, + projectileTypeId: Long + ): Option[(AutomatedTurret, Tool, SourceEntry, ProjectileDefinition)] = { + turret.Weapons + .values + .flatMap { _.Equipment } + .collect { case weapon: Tool => (turret, weapon, owner, weapon.Projectile) } + .find { case (_, _, _, p) => p.ObjectId == projectileTypeId } + } + + private def HandleAIDamage( + target: PlanetSideServerObject with FactionAffinity with Vitality, + results: Option[(AutomatedTurret, Tool, SourceEntry, ProjectileDefinition)] + ): Unit = { + results.collect { + case (obj, tool, owner, projectileInfo) => + val angle = Vector3.Unit(target.Position - obj.Position) + val proj = new Projectile( + projectileInfo, + tool.Definition, + tool.FireMode, + None, + owner, + obj.Definition.ObjectId, + obj.Position + Vector3.z(value = 1f), + angle, + Some(angle * projectileInfo.FinalVelocity) + ) + val hitPos = target.Position + Vector3.z(value = 1f) + ResolveProjectileInteraction(proj, DamageResolution.Hit, target, hitPos).collect { resprojectile => + addShotsLanded(resprojectile.cause.attribution, shots = 1) + sessionLogic.handleDealingDamage(target, resprojectile) + } + } + } + + private def UpdateProjectileSidednessAfterHit(projectile: Projectile, hitPosition: Vector3): Unit = { + val origin = projectile.Position + val distance = Vector3.Magnitude(hitPosition - origin) + continent.blockMap + .sector(hitPosition, distance) + .environmentList + .collect { case o: InteriorDoorPassage => + val door = o.door + val intersectTest = WeaponAndProjectileLogic.quickLineSphereIntersectionPoints( + origin, + hitPosition, + door.Position, + door.Definition.UseRadius + 0.1f + ) + (door, intersectTest) + } + .collect { case (door, intersectionTest) if intersectionTest.nonEmpty => + (door, Vector3.Magnitude(hitPosition - door.Position), intersectionTest) + } + .minByOption { case (_, dist, _) => dist } + .foreach { case (door, _, intersects) => + val strictly = if (Vector3.DotProduct(Vector3.Unit(hitPosition - door.Position), door.Outwards) > 0f) { + Sidedness.OutsideOf + } else { + Sidedness.InsideOf + } + projectile.WhichSide = if (intersects.size == 1) { + Sidedness.InBetweenSides(door, strictly) + } else { + strictly + } + } + } +} diff --git a/src/main/scala/net/psforever/actors/session/support/PlayerMode.scala b/src/main/scala/net/psforever/actors/session/support/PlayerMode.scala index 6eed2b2a8..bafccc852 100644 --- a/src/main/scala/net/psforever/actors/session/support/PlayerMode.scala +++ b/src/main/scala/net/psforever/actors/session/support/PlayerMode.scala @@ -3,6 +3,7 @@ package net.psforever.actors.session.support import akka.actor.Actor.Receive import akka.actor.ActorRef +import net.psforever.objects.Session trait ModeLogic { def avatarResponse: AvatarHandlerFunctions @@ -16,6 +17,10 @@ trait ModeLogic { def vehicles: VehicleFunctions def vehicleResponse: VehicleHandlerFunctions + def switchTo(session: Session): Unit = { /* to override */ } + + def switchFrom(session: Session): Unit = { /* to override */ } + def parse(sender: ActorRef): Receive } 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 0c7bb7648..70412e93d 100644 --- a/src/main/scala/net/psforever/actors/session/support/WeaponAndProjectileOperations.scala +++ b/src/main/scala/net/psforever/actors/session/support/WeaponAndProjectileOperations.scala @@ -33,6 +33,8 @@ trait WeaponAndProjectileFunctions extends CommonSessionInterfacingFunctionality def handleWeaponLazeTargetPosition(pkt: WeaponLazeTargetPositionMessage): Unit + def handleUplinkRequest(pkt: UplinkRequest): Unit + def handleAvatarGrenadeState(pkt: AvatarGrenadeStateMessage): Unit def handleChangeFireStateStart(pkt: ChangeFireStateMessage_Start): Unit 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 f40239b12..7128fb11d 100644 --- a/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala +++ b/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala @@ -1930,7 +1930,7 @@ class ZoningOperations( //find and reload previous player ( inZone.Players.find(p => p.name.equals(name)), - inZone.LivePlayers.find(p => p.Name.equals(name)) + inZone.AllPlayers.find(p => p.Name.equals(name)) ) match { case (_, Some(p)) if p.death_by == -1 => //player is not allowed @@ -2905,36 +2905,34 @@ class ZoningOperations( def HandleSetCurrentAvatar(tplayer: Player): Unit = { log.trace(s"HandleSetCurrentAvatar - ${tplayer.Name}") session = session.copy(player = tplayer) + val tavatar = tplayer.avatar val guid = tplayer.GUID - sessionLogic.general.updateDeployableUIElements(Deployables.InitializeDeployableUIElements(avatar)) + sessionLogic.general.updateDeployableUIElements(Deployables.InitializeDeployableUIElements(tavatar)) sendResponse(PlanetsideAttributeMessage(PlanetSideGUID(0), 75, 0)) sendResponse(SetCurrentAvatarMessage(guid, 0, 0)) sendResponse(ChatMsg(ChatMessageType.CMT_EXPANSIONS, wideContents=true, "", "1 on", None)) //CC on //TODO once per respawn? - val pos = player.Position = shiftPosition.getOrElse(tplayer.Position) - val orient = player.Orientation = shiftOrientation.getOrElse(tplayer.Orientation) + val pos = tplayer.Position = shiftPosition.getOrElse(tplayer.Position) + val orient = tplayer.Orientation = shiftOrientation.getOrElse(tplayer.Orientation) sendResponse(PlayerStateShiftMessage(ShiftState(1, pos, orient.z))) shiftPosition = None shiftOrientation = None - if (player.spectator) { - sendResponse(ChatMsg(ChatMessageType.CMT_TOGGLESPECTATORMODE, wideContents=false, "", "on", None)) - } - if (player.Jammed) { + if (tplayer.Jammed) { //TODO something better than just canceling? - player.Actor ! JammableUnit.ClearJammeredStatus() - player.Actor ! JammableUnit.ClearJammeredSound() + tplayer.Actor ! JammableUnit.ClearJammeredStatus() + tplayer.Actor ! JammableUnit.ClearJammeredSound() } val originalDeadState = deadState deadState = DeadState.Alive avatarActor ! AvatarActor.ResetImplants() sendResponse(PlanetsideAttributeMessage(PlanetSideGUID(0), 82, 0)) - initializeShortcutsAndBank(guid) + initializeShortcutsAndBank(guid, tavatar.shortcuts) //Favorites lists avatarActor ! AvatarActor.InitialRefreshLoadouts() sendResponse( SetChatFilterMessage(ChatChannel.Platoon, origin = false, ChatChannel.values.toList) ) //TODO will not always be "on" like this - sendResponse(AvatarDeadStateMessage(DeadState.Alive, 0, 0, tplayer.Position, player.Faction, unk5 = true)) + sendResponse(AvatarDeadStateMessage(DeadState.Alive, 0, 0, tplayer.Position, tplayer.Faction, unk5 = true)) //looking for squad (members) if (tplayer.avatar.lookingForSquad) { sendResponse(PlanetsideAttributeMessage(guid, 53, 1)) @@ -2979,7 +2977,7 @@ class ZoningOperations( drawDeloyableIcon = DontRedrawIcons //assert or transfer vehicle ownership - continent.GUID(player.avatar.vehicle) match { + continent.GUID(tplayer.avatar.vehicle) match { case Some(vehicle: Vehicle) if vehicle.OwnerName.contains(tplayer.Name) => vehicle.OwnerGuid = guid continent.VehicleEvents ! VehicleServiceMessage( @@ -3033,7 +3031,7 @@ class ZoningOperations( ) case (Some(vehicle), _) => //passenger - vehicle.Actor ! Vehicle.UpdateZoneInteractionProgressUI(player) + vehicle.Actor ! Vehicle.UpdateZoneInteractionProgressUI(tplayer) case _ => ; } interstellarFerryTopLevelGUID = None @@ -3042,12 +3040,12 @@ class ZoningOperations( loadConfZone = false } if (noSpawnPointHere) { - RequestSanctuaryZoneSpawn(player, continent.Number) - } else if (originalDeadState == DeadState.Dead || player.Health == 0) { + RequestSanctuaryZoneSpawn(tplayer, continent.Number) + } else if (originalDeadState == DeadState.Dead || tplayer.Health == 0) { //killed during spawn setup or possibly a relog into a corpse (by accident?) - player.Actor ! Player.Die() + tplayer.Actor ! Player.Die() } else { - AvatarActor.savePlayerData(player) + AvatarActor.savePlayerData(tplayer) sessionLogic.general.displayCharSavedMsgThenRenewTimer( Config.app.game.savedMsg.short.fixed, Config.app.game.savedMsg.short.variable @@ -3061,14 +3059,14 @@ class ZoningOperations( } .collect { case Some(thing: PlanetSideGameObject with FactionAffinity) => Some(SourceEntry(thing)) } .flatten - val lastEntryOpt = player.History.lastOption + val lastEntryOpt = tplayer.History.lastOption if (lastEntryOpt.exists { !_.isInstanceOf[IncarnationActivity] }) { - player.LogActivity({ + tplayer.LogActivity({ lastEntryOpt match { case Some(_) => - ReconstructionActivity(PlayerSource(player), continent.Number, effortBy) + ReconstructionActivity(PlayerSource(tplayer), continent.Number, effortBy) case None => - SpawningActivity(PlayerSource(player), continent.Number, effortBy) + SpawningActivity(PlayerSource(tplayer), continent.Number, effortBy) } }) } @@ -3079,9 +3077,9 @@ class ZoningOperations( !account.gm && /* gm's are excluded */ Config.app.game.promotion.active && /* play versus progress system must be active */ BattleRank.withExperience(tplayer.avatar.bep).value <= Config.app.game.promotion.broadcastBattleRank && /* must be below a certain battle rank */ - avatar.scorecard.Lives.isEmpty && /* first life after login */ - avatar.scorecard.CurrentLife.prior.isEmpty && /* no revives */ - player.History.size == 1 /* did nothing but come into existence */ + tavatar.scorecard.Lives.isEmpty && /* first life after login */ + tavatar.scorecard.CurrentLife.prior.isEmpty && /* no revives */ + tplayer.History.size == 1 /* did nothing but come into existence */ ) { ZoningOperations.reportProgressionSystem(context.self) } @@ -3149,6 +3147,18 @@ class ZoningOperations( } sendResponse(ChangeShortcutBankMessage(guid, 0)) } + def initializeShortcutsAndBank(guid: PlanetSideGUID, shortcuts: Array[Option[AvatarShortcut]]): Unit = { + shortcuts + .zipWithIndex + .collect { case (Some(shortcut), index) => + sendResponse(CreateShortcutMessage( + guid, + index + 1, + Some(AvatarShortcut.convert(shortcut)) + )) + } + sendResponse(ChangeShortcutBankMessage(guid, 0)) + } /** * Draw the icon for this deployable object.
diff --git a/src/main/scala/net/psforever/objects/avatar/FirstTimeEvents.scala b/src/main/scala/net/psforever/objects/avatar/FirstTimeEvents.scala index a196bb21d..a527095ea 100644 --- a/src/main/scala/net/psforever/objects/avatar/FirstTimeEvents.scala +++ b/src/main/scala/net/psforever/objects/avatar/FirstTimeEvents.scala @@ -463,4 +463,9 @@ object FirstTimeEvents { "xpe_th_flail", "xpe_th_bfr" ) + + val All: Set[String] = NC.All ++ TR.All ++ VS.All ++ + Standard.All ++ Cavern.All ++ + Maps ++ Monoliths ++ Gingerman ++ Sled ++ Snowman ++ Charlie ++ BattleRanks ++ CommandRanks ++ + Training ++ OldTraining ++ Generic } diff --git a/src/main/scala/net/psforever/objects/definition/converter/AvatarConverter.scala b/src/main/scala/net/psforever/objects/definition/converter/AvatarConverter.scala index 62b3eeff3..31f5f3992 100644 --- a/src/main/scala/net/psforever/objects/definition/converter/AvatarConverter.scala +++ b/src/main/scala/net/psforever/objects/definition/converter/AvatarConverter.scala @@ -69,7 +69,7 @@ object AvatarConverter { obj.avatar.basic, CommonFieldData( obj.Faction, - bops = false, + bops = obj.spectator, alt_model_flag, v1 = false, None, diff --git a/src/main/scala/net/psforever/objects/serverobject/terminals/EquipmentTerminalDefinition.scala b/src/main/scala/net/psforever/objects/serverobject/terminals/EquipmentTerminalDefinition.scala index 950802b77..79621d375 100644 --- a/src/main/scala/net/psforever/objects/serverobject/terminals/EquipmentTerminalDefinition.scala +++ b/src/main/scala/net/psforever/objects/serverobject/terminals/EquipmentTerminalDefinition.scala @@ -204,7 +204,7 @@ object EquipmentTerminalDefinition { "advanced_ace" -> MakeConstructionItem(advanced_ace), "remote_electronics_kit" -> MakeSimpleItem(remote_electronics_kit), "trek" -> MakeTool(trek), - "command_detonater" -> MakeSimpleItem(command_detonater), + //"command_detonater" -> MakeSimpleItem(command_detonater), "flail_targeting_laser" -> MakeSimpleItem(flail_targeting_laser) ) diff --git a/src/main/scala/net/psforever/objects/zones/Zone.scala b/src/main/scala/net/psforever/objects/zones/Zone.scala index 22acf7b44..6e6d7aef2 100644 --- a/src/main/scala/net/psforever/objects/zones/Zone.scala +++ b/src/main/scala/net/psforever/objects/zones/Zone.scala @@ -566,9 +566,13 @@ class Zone(val id: String, val map: ZoneMap, zoneNumber: Int) { def Vehicles: List[Vehicle] = vehicles.toList - def Players: List[Avatar] = players.values.flatten.map(_.avatar).toList + def AllPlayers: List[Player] = players.values.flatten.toList - def LivePlayers: List[Player] = players.values.flatten.toList + def Players: List[Avatar] = AllPlayers.map(_.avatar) + + def LivePlayers: List[Player] = AllPlayers.filterNot(_.spectator) + + def Spectator: List[Player] = AllPlayers.filter(_.spectator) def Corpses: List[Player] = corpses.toList diff --git a/src/main/scala/net/psforever/packet/GamePacketOpcode.scala b/src/main/scala/net/psforever/packet/GamePacketOpcode.scala index 291928c6c..bea0c62eb 100644 --- a/src/main/scala/net/psforever/packet/GamePacketOpcode.scala +++ b/src/main/scala/net/psforever/packet/GamePacketOpcode.scala @@ -489,14 +489,14 @@ object GamePacketOpcode extends Enumeration { case 0x9b => noDecoder(SyncMessage) case 0x9c => game.DebugDrawMessage.decode case 0x9d => noDecoder(SoulMarkMessage) - case 0x9e => noDecoder(UplinkPositionEvent) + case 0x9e => game.UplinkPositionEvent.decode case 0x9f => game.HotSpotUpdateMessage.decode // OPCODES 0xa0-af case 0xa0 => game.BuildingInfoUpdateMessage.decode case 0xa1 => game.FireHintMessage.decode - case 0xa2 => noDecoder(UplinkRequest) - case 0xa3 => noDecoder(UplinkResponse) + case 0xa2 => game.UplinkRequest.decode + case 0xa3 => game.UplinkResponse.decode case 0xa4 => game.WarpgateRequest.decode case 0xa5 => noDecoder(WarpgateResponse) case 0xa6 => game.DamageWithPositionMessage.decode diff --git a/src/main/scala/net/psforever/packet/game/UplinkPositionEvent.scala b/src/main/scala/net/psforever/packet/game/UplinkPositionEvent.scala new file mode 100644 index 000000000..ede79cf4d --- /dev/null +++ b/src/main/scala/net/psforever/packet/game/UplinkPositionEvent.scala @@ -0,0 +1,114 @@ +// Copyright (c) 2024 PSForever +package net.psforever.packet.game + +import net.psforever.packet.GamePacketOpcode.Type +import net.psforever.packet.{GamePacketOpcode, Marshallable, PlanetSideGamePacket} +import shapeless.{::, HNil} +import net.psforever.types.Vector3 +import scodec.bits.BitVector +import scodec.{Attempt, Codec} +import scodec.codecs._ + +import scala.annotation.switch + +trait UplinkEvent { + def code: Int +} + +final case class Event0(code: Int) extends UplinkEvent + +final case class Event1(code: Int, unk1: Int) extends UplinkEvent + +final case class Event2( + code: Int, + unk1: Vector3, + unk2: Int, + unk3: Int, + unk4: Long, + unk5: Long, + unk6: Long, + unk7: Long, + unk8: Option[Boolean] + ) extends UplinkEvent + +final case class UplinkPositionEvent( + code: Int, + event: UplinkEvent + ) extends PlanetSideGamePacket { + type Packet = UplinkPositionEvent + def opcode: Type = GamePacketOpcode.UplinkPositionEvent + def encode: Attempt[BitVector] = UplinkPositionEvent.encode(this) +} + +object UplinkPositionEvent extends Marshallable[UplinkPositionEvent] { + private def event0Codec(code: Int): Codec[Event0] = conditional(included = false, bool).xmap[Event0]( + _ => Event0(code), + { + case Event0(_) => None + } + ) + + private def event1Codec(code: Int): Codec[Event1] = + ("unk1" | uint8L).hlist.xmap[Event1]( + { + case unk1 :: HNil => Event1(code, unk1) + }, + { + case Event1(_, unk1) => unk1 :: HNil + } + ) + + private def event2NoBoolCodec(code: Int): Codec[Event2] = ( + ("unk1" | Vector3.codec_pos) :: + ("unk2" | uint8) :: + ("unk3" | uint16L) :: + ("unk4" | uint32L) :: + ("unk5" | uint32L) :: + ("unk6" | uint32L) :: + ("unk7" | uint32L) + ).xmap[Event2]( + { + case u1 :: u2 :: u3 :: u4 :: u5 :: u6 :: u7 :: HNil => + Event2(code, u1, u2, u3, u4, u5, u6, u7, None) + }, + { + case Event2(_, u1, u2, u3, u4, u5, u6, u7, _) => + u1 :: u2 :: u3 :: u4 :: u5 :: u6 :: u7 :: HNil + } + ) + + private def event2WithBoolCodec(code: Int): Codec[Event2] = ( + ("unk1" | Vector3.codec_pos) :: + ("unk2" | uint8) :: + ("unk3" | uint16L) :: + ("unk4" | uint32L) :: + ("unk5" | uint32L) :: + ("unk6" | uint32L) :: + ("unk7" | uint32L) :: + ("unk8" | bool) + ).xmap[Event2]( + { + case u1 :: u2 :: u3 :: u4 :: u5 :: u6 :: u7 :: u8 :: HNil => + Event2(code, u1, u2, u3, u4, u5, u6, u7, Some(u8)) + }, + { + case Event2(_, u1, u2, u3, u4, u5, u6, u7, Some(u8)) => + u1 :: u2 :: u3 :: u4 :: u5 :: u6 :: u7 :: u8 :: HNil + } + ) + + private def switchUplinkEvent(code: Int): Codec[UplinkEvent] = { + ((code: @switch) match { + case 0 => event2NoBoolCodec(code) + case 1 | 2 => event2WithBoolCodec(code) + case 3 | 4 => event1Codec(code) + case _ => event0Codec(code) + }).asInstanceOf[Codec[UplinkEvent]] + } + + implicit val codec: Codec[UplinkPositionEvent] = ( + ("code" | uint(bits = 3)) >>:~ { code => + ("event" | switchUplinkEvent(code)).hlist + } + ).as[UplinkPositionEvent] +} diff --git a/src/main/scala/net/psforever/packet/game/UplinkRequest.scala b/src/main/scala/net/psforever/packet/game/UplinkRequest.scala new file mode 100644 index 000000000..24c3cc162 --- /dev/null +++ b/src/main/scala/net/psforever/packet/game/UplinkRequest.scala @@ -0,0 +1,59 @@ +// Copyright (c) 2024 PSForever +package net.psforever.packet.game + +import enumeratum.values.{IntEnum, IntEnumEntry} +import shapeless.{::, HNil} +import net.psforever.newcodecs.newcodecs +import net.psforever.packet.GamePacketOpcode.Type +import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket} +import net.psforever.types.Vector3 +import scodec.bits.BitVector +import scodec.{Attempt, Codec} +import scodec.codecs._ + +sealed abstract class UplinkRequestType(val value: Int) extends IntEnumEntry + +object UplinkRequestType extends IntEnum[UplinkRequestType] { + val values: IndexedSeq[UplinkRequestType] = findValues + + case object RevealFriendlies extends UplinkRequestType(value = 0) + + case object RevealEnemies extends UplinkRequestType(value = 1) + + case object Unknown2 extends UplinkRequestType(value = 2) + + case object ElectroMagneticPulse extends UplinkRequestType(value = 3) + + case object OrbitalStrike extends UplinkRequestType(value = 4) + + implicit val codec: Codec[UplinkRequestType] = PacketHelpers.createIntEnumCodec(this, uint4) +} + +final case class UplinkRequest( + uplinkType: UplinkRequestType, + pos: Option[Vector3], + unk: Boolean + ) extends PlanetSideGamePacket { + type Packet = UplinkRequest + def opcode: Type = GamePacketOpcode.UplinkRequest + def encode: Attempt[BitVector] = UplinkRequest.encode(this) +} + +object UplinkRequest extends Marshallable[UplinkRequest] { + private val xyToVector3: Codec[Vector3] = + (newcodecs.q_float(0.0, 8192.0, 20) :: + newcodecs.q_float(0.0, 8192.0, 20)).xmap[Vector3]( + { + case x :: y :: HNil => Vector3(x, y, 0f) + }, + { + case Vector3(x, y, _) => x :: y :: HNil + } + ) + + implicit val codec: Codec[UplinkRequest] = ( + ("uplinkType" | UplinkRequestType.codec) >>:~ { uplinkType => + conditional(uplinkType == UplinkRequestType.OrbitalStrike, xyToVector3) :: + ("unk" | bool) + }).as[UplinkRequest] +} diff --git a/src/main/scala/net/psforever/packet/game/UplinkResponse.scala b/src/main/scala/net/psforever/packet/game/UplinkResponse.scala new file mode 100644 index 000000000..b1aef366f --- /dev/null +++ b/src/main/scala/net/psforever/packet/game/UplinkResponse.scala @@ -0,0 +1,24 @@ +// Copyright (c) 2024 PSForever +package net.psforever.packet.game + +import net.psforever.packet.GamePacketOpcode.Type +import net.psforever.packet.{GamePacketOpcode, Marshallable, PlanetSideGamePacket} +import scodec.bits.BitVector +import scodec.{Attempt, Codec} +import scodec.codecs._ + +final case class UplinkResponse( + unk1: Int, + unk2: Int + ) extends PlanetSideGamePacket { + type Packet = UplinkResponse + def opcode: Type = GamePacketOpcode.UplinkResponse + def encode: Attempt[BitVector] = UplinkResponse.encode(this) +} + +object UplinkResponse extends Marshallable[UplinkResponse] { + implicit val codec: Codec[UplinkResponse] = ( + ("unk1" | uint(bits = 3)) :: + ("unk2" | uint4) + ).as[UplinkResponse] +} diff --git a/src/main/scala/net/psforever/services/account/AccountPersistenceService.scala b/src/main/scala/net/psforever/services/account/AccountPersistenceService.scala index 2576bfedf..1d2fe0f4f 100644 --- a/src/main/scala/net/psforever/services/account/AccountPersistenceService.scala +++ b/src/main/scala/net/psforever/services/account/AccountPersistenceService.scala @@ -363,7 +363,7 @@ class PersistenceMonitor( * but should be uncommon. */ def PerformLogout(): Unit = { - (inZone.Players.find(p => p.name == name), inZone.LivePlayers.find(p => p.Name == name)) match { + (inZone.Players.find(p => p.name == name), inZone.AllPlayers.find(p => p.Name == name)) match { case (Some(avatar), Some(player)) if player.VehicleSeated.nonEmpty => //in case the player is holding the llu and disconnects player.Zone.AvatarEvents ! AvatarServiceMessage(player.Name, AvatarAction.DropSpecialItem())