From 30a4eba6463531d586e6834e8a5500d1520fee30 Mon Sep 17 00:00:00 2001 From: Fate-JH Date: Fri, 27 Sep 2024 01:02:13 -0400 Subject: [PATCH] initial workings for a csr/gm player mode --- src/main/resources/application.conf | 2 +- .../actors/session/AvatarActor.scala | 8 +- .../session/csr/AvatarHandlerLogic.scala | 586 +++++++ .../actors/session/csr/ChatLogic.scala | 238 +++ .../CustomerServiceRepresentativeMode.scala | 46 + .../session/csr/GalaxyHandlerLogic.scala | 86 + .../actors/session/csr/GeneralLogic.scala | 1540 +++++++++++++++++ .../session/csr/LocalHandlerLogic.scala | 268 +++ .../session/csr/MountHandlerLogic.scala | 520 ++++++ ...eAsCustomerServiceRepresentativeMode.scala | 120 ++ .../session/csr/SquadHandlerLogic.scala | 358 ++++ .../session/csr/TerminalHandlerLogic.scala | 184 ++ .../session/csr/VehicleHandlerLogic.scala | 399 +++++ .../actors/session/csr/VehicleLogic.scala | 355 ++++ .../csr/WeaponAndProjectileLogic.scala | 1379 +++++++++++++++ .../actors/session/normal/ChatLogic.scala | 140 +- .../actors/session/normal/GeneralLogic.scala | 5 +- .../session/support/ChatOperations.scala | 22 +- .../session/support/ZoningOperations.scala | 4 +- 19 files changed, 6135 insertions(+), 125 deletions(-) create mode 100644 src/main/scala/net/psforever/actors/session/csr/AvatarHandlerLogic.scala create mode 100644 src/main/scala/net/psforever/actors/session/csr/ChatLogic.scala create mode 100644 src/main/scala/net/psforever/actors/session/csr/CustomerServiceRepresentativeMode.scala create mode 100644 src/main/scala/net/psforever/actors/session/csr/GalaxyHandlerLogic.scala create mode 100644 src/main/scala/net/psforever/actors/session/csr/GeneralLogic.scala create mode 100644 src/main/scala/net/psforever/actors/session/csr/LocalHandlerLogic.scala create mode 100644 src/main/scala/net/psforever/actors/session/csr/MountHandlerLogic.scala create mode 100644 src/main/scala/net/psforever/actors/session/csr/SpectateAsCustomerServiceRepresentativeMode.scala create mode 100644 src/main/scala/net/psforever/actors/session/csr/SquadHandlerLogic.scala create mode 100644 src/main/scala/net/psforever/actors/session/csr/TerminalHandlerLogic.scala create mode 100644 src/main/scala/net/psforever/actors/session/csr/VehicleHandlerLogic.scala create mode 100644 src/main/scala/net/psforever/actors/session/csr/VehicleLogic.scala create mode 100644 src/main/scala/net/psforever/actors/session/csr/WeaponAndProjectileLogic.scala diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index 9c603e2a0..5d444811e 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -27,7 +27,7 @@ world { # How the server is displayed in the server browser. # One of: released beta development - server-type = released + server-type = development } # Admin API configuration diff --git a/src/main/scala/net/psforever/actors/session/AvatarActor.scala b/src/main/scala/net/psforever/actors/session/AvatarActor.scala index 8a243b6dc..91230d19b 100644 --- a/src/main/scala/net/psforever/actors/session/AvatarActor.scala +++ b/src/main/scala/net/psforever/actors/session/AvatarActor.scala @@ -1061,17 +1061,19 @@ object AvatarActor { val result = ctx.run(query[persistence.Avatarmodepermission].filter(_.avatarId == lift(avatarId))) result.onComplete { case Success(res) => + val isDevServer = Config.app.world.serverType == ServerType.Development res.headOption .collect { case perms: persistence.Avatarmodepermission => - out.completeWith(Future(ModePermissions(perms.canSpectate, perms.canGm))) + out.completeWith(Future(ModePermissions(perms.canSpectate || isDevServer, perms.canGm || isDevServer))) } .orElse { - out.completeWith(Future(ModePermissions())) + out.completeWith(Future(ModePermissions(isDevServer, isDevServer))) None } case _ => - out.completeWith(Future(ModePermissions())) + val isDevServer = Config.app.world.serverType == ServerType.Development + out.completeWith(Future(ModePermissions(isDevServer, isDevServer))) } out.future } diff --git a/src/main/scala/net/psforever/actors/session/csr/AvatarHandlerLogic.scala b/src/main/scala/net/psforever/actors/session/csr/AvatarHandlerLogic.scala new file mode 100644 index 000000000..4833ce14e --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/csr/AvatarHandlerLogic.scala @@ -0,0 +1,586 @@ +// Copyright (c) 2024 PSForever +package net.psforever.actors.session.csr + +import akka.actor.{ActorContext, typed} +import net.psforever.actors.session.support.AvatarHandlerFunctions +import net.psforever.objects.definition.converter.OCM +import net.psforever.packet.game.{AvatarImplantMessage, CreateShortcutMessage, ImplantAction} +import net.psforever.types.ImplantType + +// +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.terminals.{ProximityUnit, Terminal} +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.AvatarImplant(ImplantAction.Add, implant_slot, value) + if value == ImplantType.SecondWind.value => + sendResponse(AvatarImplantMessage(resolvedPlayerGuid, ImplantAction.Add, implant_slot, 7)) + //second wind does not normally load its icon into the shortcut hotbar + avatar + .shortcuts + .zipWithIndex + .find { case (s, _) => s.isEmpty} + .foreach { case (_, index) => + sendResponse(CreateShortcutMessage(resolvedPlayerGuid, index + 1, Some(ImplantType.SecondWind.shortcut))) + } + + case AvatarResponse.AvatarImplant(ImplantAction.Remove, implant_slot, value) + if value == ImplantType.SecondWind.value => + sendResponse(AvatarImplantMessage(resolvedPlayerGuid, ImplantAction.Remove, implant_slot, value)) + //second wind does not normally unload its icon from the shortcut hotbar + val shortcut = { + val imp = ImplantType.SecondWind.shortcut + net.psforever.objects.avatar.Shortcut(imp.code, imp.tile) //case class + } + avatar + .shortcuts + .zipWithIndex + .find { case (s, _) => s.contains(shortcut) } + .foreach { case (_, index) => + sendResponse(CreateShortcutMessage(resolvedPlayerGuid, index + 1, None)) + } + + case AvatarResponse.AvatarImplant(action, implant_slot, value) => + sendResponse(AvatarImplantMessage(resolvedPlayerGuid, action, implant_slot, value)) + + 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) => + //pure logic + sessionLogic.shooting.shotsWhileDead = 0 + + //player state changes + sessionLogic.zoning.spawn.reviveTimer.cancel() + player.Revive + val health = player.Health + sendResponse(PlanetsideAttributeMessage(player.GUID, 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(player.GUID, attribute_type=0, health) + ) + avatarActor ! AvatarActor.InitializeImplants + AvatarActor.updateToolDischargeFor(avatar) + player.FreeHand.Equipment.foreach { item => + DropEquipmentFromInventory(player)(item) + } + continent.GUID(mount) + .collect { + case obj: Vehicle if obj.Destroyed => + sessionLogic.vehicles.ConditionalDriverVehicleControl(obj) + sessionLogic.general.unaccessContainer(obj) + player.VehicleSeated = None + sendResponse(OCM.detailed(player)) + case _: Vehicle => + player.VehicleSeated = None + sendResponse(OCM.detailed(player)) + } + sendResponse(ChatMsg(ChatMessageType.UNK_225, "CSR MODE")) + + 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/csr/ChatLogic.scala b/src/main/scala/net/psforever/actors/session/csr/ChatLogic.scala new file mode 100644 index 000000000..781ea7e50 --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/csr/ChatLogic.scala @@ -0,0 +1,238 @@ +// Copyright (c) 2024 PSForever +package net.psforever.actors.session.csr + +import akka.actor.ActorContext +import net.psforever.actors.session.support.{ChatFunctions, ChatOperations, SessionData} +import net.psforever.objects.Session +import net.psforever.packet.game.{ChatMsg, SetChatFilterMessage} +import net.psforever.services.chat.DefaultChannel +import net.psforever.types.ChatMessageType + +object ChatLogic { + def apply(ops: ChatOperations): ChatLogic = { + new ChatLogic(ops, ops.context) + } +} + +class ChatLogic(val ops: ChatOperations, implicit val context: ActorContext) extends ChatFunctions { + def sessionLogic: SessionData = ops.sessionLogic + + ops.SpectatorMode = SpectateAsCustomerServiceRepresentativeMode + + def handleChatMsg(message: ChatMsg): Unit = { + import net.psforever.types.ChatMessageType._ + val isAlive = if (player != null) player.isAlive else false + (message.messageType, message.recipient.trim, message.contents.trim) match { + /** Messages starting with ! are custom chat commands */ + case (_, _, contents) if contents.startsWith("!") && + customCommandMessages(message, session) => () + + case (CMT_FLY, recipient, contents) => + ops.commandFly(contents, recipient) + + case (CMT_ANONYMOUS, _, _) => + // ? + + case (CMT_TOGGLE_GM, _, contents)=> + customCommandModerator(contents) + + case (CMT_CULLWATERMARK, _, contents) => + ops.commandWatermark(contents) + + case (CMT_SPEED, _, contents) => + ops.commandSpeed(message, contents) + + case (CMT_TOGGLESPECTATORMODE, _, contents) if isAlive => + commandToggleSpectatorMode(contents) + + case (CMT_RECALL, _, _) => + ops.commandRecall(session) + + case (CMT_INSTANTACTION, _, _) => + ops.commandInstantAction(session) + + case (CMT_QUIT, _, _) => + ops.commandQuit(session) + + case (CMT_SUICIDE, _, _) => + ops.commandSuicide(session) + + case (CMT_DESTROY, _, contents) if contents.matches("\\d+") => + ops.commandDestroy(session, message, contents) + + case (CMT_SETBASERESOURCES, _, contents) => + ops.commandSetBaseResources(session, contents) + + case (CMT_ZONELOCK, _, contents) => + ops.commandZoneLock(contents) + + case (U_CMT_ZONEROTATE, _, _) => + ops.commandZoneRotate() + + case (CMT_CAPTUREBASE, _, contents) => + ops.commandCaptureBase(session, message, contents) + + case (CMT_GMBROADCAST | CMT_GMBROADCAST_NC | CMT_GMBROADCAST_VS | CMT_GMBROADCAST_TR, _, _) => + ops.commandSendToRecipient(session, message, DefaultChannel) + + case (CMT_GMTELL, _, _) => + ops.commandSend(session, message, DefaultChannel) + + case (CMT_GMBROADCASTPOPUP, _, _) => + ops.commandSendToRecipient(session, message, DefaultChannel) + + case (CMT_OPEN, _, _) if !player.silenced => + ops.commandSendToRecipient(session, message, DefaultChannel) + + case (CMT_VOICE, _, contents) => + ops.commandVoice(session, message, contents, DefaultChannel) + + case (CMT_TELL, _, _) if !player.silenced => + ops.commandTellOrIgnore(session, message, DefaultChannel) + + case (CMT_BROADCAST, _, _) if !player.silenced => + ops.commandSendToRecipient(session, message, DefaultChannel) + + case (CMT_PLATOON, _, _) if !player.silenced => + ops.commandSendToRecipient(session, message, DefaultChannel) + + case (CMT_COMMAND, _, _) => + ops.commandSendToRecipient(session, message, DefaultChannel) + + case (CMT_NOTE, _, _) => + ops.commandSend(session, message, DefaultChannel) + + case (CMT_SILENCE, _, _) => + ops.commandSend(session, message, DefaultChannel) + + case (CMT_SQUAD, _, _) => + ops.commandSquad(session, message, DefaultChannel) //todo SquadChannel, but what is the guid + + case (CMT_WHO | CMT_WHO_CSR | CMT_WHO_CR | CMT_WHO_PLATOONLEADERS | CMT_WHO_SQUADLEADERS | CMT_WHO_TEAMS, _, _) => + ops.commandWho(session) + + case (CMT_ZONE, _, contents) => + ops.commandZone(message, contents) + + case (CMT_WARP, _, contents) => + ops.commandWarp(session, message, contents) + + case (CMT_SETBATTLERANK, _, contents) => + ops.commandSetBattleRank(session, message, contents) + + case (CMT_SETCOMMANDRANK, _, contents) => + ops.commandSetCommandRank(session, message, contents) + + case (CMT_ADDBATTLEEXPERIENCE, _, contents) => + ops.commandAddBattleExperience(message, contents) + + case (CMT_ADDCOMMANDEXPERIENCE, _, contents) => + ops.commandAddCommandExperience(message, contents) + + case (CMT_TOGGLE_HAT, _, contents) => + ops.commandToggleHat(session, message, contents) + + case (CMT_HIDE_HELMET | CMT_TOGGLE_SHADES | CMT_TOGGLE_EARPIECE, _, contents) => + ops.commandToggleCosmetics(session, message, contents) + + case (CMT_ADDCERTIFICATION, _, contents) => + ops.commandAddCertification(session, message, contents) + + case (CMT_KICK, _, contents) => + ops.commandKick(session, message, contents) + + case _ => + log.warn(s"Unhandled chat message $message") + } + } + + def handleChatFilter(pkt: SetChatFilterMessage): Unit = { + val SetChatFilterMessage(_, _, _) = pkt + } + + def handleIncomingMessage(message: ChatMsg, fromSession: Session): Unit = { + import ChatMessageType._ + message.messageType match { + case CMT_BROADCAST | CMT_SQUAD | CMT_PLATOON | CMT_COMMAND | CMT_NOTE => + ops.commandIncomingSendAllIfOnline(session, message) + + case CMT_OPEN => + ops.commandIncomingSendToLocalIfOnline(session, fromSession, message) + + case CMT_TELL | U_CMT_TELLFROM | + CMT_GMOPEN | CMT_GMBROADCAST | CMT_GMBROADCAST_NC | CMT_GMBROADCAST_TR | CMT_GMBROADCAST_VS | + CMT_GMBROADCASTPOPUP | CMT_GMTELL | U_CMT_GMTELLFROM | UNK_45 | UNK_71 | UNK_227 | UNK_229 => + ops.commandIncomingSend(message) + + case CMT_VOICE => + ops.commandIncomingVoice(session, fromSession, message) + + case CMT_SILENCE => + ops.commandIncomingSilence(session, message) + + case _ => + log.warn(s"Unexpected messageType $message") + } + } + + private def customCommandMessages( + message: ChatMsg, + session: Session + ): Boolean = { + val contents = message.contents + if (contents.startsWith("!")) { + val (command, params) = ops.cliTokenization(contents.drop(1)) match { + case a :: b => (a, b) + case _ => ("", Seq("")) + } + command match { + case "loc" => ops.customCommandLoc(session, message) + case "suicide" => ops.customCommandSuicide(session) + case "grenade" => ops.customCommandGrenade(session, log) + case "macro" => ops.customCommandMacro(session, params) + case "progress" => ops.customCommandProgress(session, params) + case "whitetext" => ops.customCommandWhitetext(session, params) + case "list" => ops.customCommandList(session, params, message) + case "ntu" => ops.customCommandNtu(session, params) + case "zonerotate" => ops.customCommandZonerotate(params) + case "nearby" => ops.customCommandNearby(session) + case "csr" | "gm" | "op" => customCommandModerator(params.headOption.getOrElse("")) + case _ => + // command was not handled + sendResponse( + ChatMsg( + ChatMessageType.CMT_GMOPEN, // CMT_GMTELL + message.wideContents, + "Server", + s"Unknown command !$command", + message.note + ) + ) + false + } + } else { + false + } + } + + def commandToggleSpectatorMode(contents: String): Unit = { +// val currentSpectatorActivation = (if (avatar != null) avatar.permissions else ModePermissions()).canSpectate +// contents.toLowerCase() match { +// case "on" | "o" | "" if !currentSpectatorActivation => +// context.self ! SessionActor.SetMode(SessionSpectatorMode) +// case "off" | "of" if currentSpectatorActivation => +// context.self ! SessionActor.SetMode(SessionCustomerServiceRepresentativeMode) +// case _ => () +// } + } + + def customCommandModerator(contents : String): Boolean = { + if (player.spectator) { + sendResponse(ChatMsg(ChatMessageType.UNK_225, "CSR SPECTATOR MODE")) + sendResponse(ChatMsg(ChatMessageType.UNK_227, "Disable spectator mode first.")) + } else { + ops.customCommandModerator(contents) + } + true + } +} diff --git a/src/main/scala/net/psforever/actors/session/csr/CustomerServiceRepresentativeMode.scala b/src/main/scala/net/psforever/actors/session/csr/CustomerServiceRepresentativeMode.scala new file mode 100644 index 000000000..41c530b4c --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/csr/CustomerServiceRepresentativeMode.scala @@ -0,0 +1,46 @@ +// Copyright (c) 2024 PSForever +package net.psforever.actors.session.csr + +import net.psforever.actors.session.support.{ChatFunctions, GeneralFunctions, LocalHandlerFunctions, ModeLogic, MountHandlerFunctions, PlayerMode, SessionData, SquadHandlerFunctions, TerminalHandlerFunctions, VehicleFunctions, VehicleHandlerFunctions, WeaponAndProjectileFunctions} +import net.psforever.actors.zone.ZoneActor +import net.psforever.objects.Session +import net.psforever.packet.PlanetSidePacket +import net.psforever.packet.game.ChatMsg +import net.psforever.types.ChatMessageType + +class CustomerServiceRepresentativeMode(data: SessionData) extends ModeLogic { + val avatarResponse: AvatarHandlerLogic = AvatarHandlerLogic(data.avatarResponse) + val chat: ChatFunctions = ChatLogic(data.chat) + 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) + + override def switchTo(session: Session): Unit = { + val player = session.player + val continent = session.zone + val sendResponse: PlanetSidePacket=>Unit = data.sendResponse + // + continent.actor ! ZoneActor.RemoveFromBlockMap(player) + sendResponse(ChatMsg(ChatMessageType.UNK_225, "CSR MODE ON")) + } + + override def switchFrom(session: Session): Unit = { + val player = data.player + val sendResponse: PlanetSidePacket => Unit = data.sendResponse + // + data.continent.actor ! ZoneActor.AddToBlockMap(player, player.Position) + sendResponse(ChatMsg(ChatMessageType.UNK_225, "CSR MODE OFF")) + } +} + +case object CustomerServiceRepresentativeMode extends PlayerMode { + def setup(data: SessionData): ModeLogic = { + new CustomerServiceRepresentativeMode(data) + } +} diff --git a/src/main/scala/net/psforever/actors/session/csr/GalaxyHandlerLogic.scala b/src/main/scala/net/psforever/actors/session/csr/GalaxyHandlerLogic.scala new file mode 100644 index 000000000..cf9b9932b --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/csr/GalaxyHandlerLogic.scala @@ -0,0 +1,86 @@ +// Copyright (c) 2024 PSForever +package net.psforever.actors.session.csr + +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 pop = zone.LivePlayers.distinctBy(_.CharId) + val popTR = pop.count(_.Faction == PlanetSideEmpire.TR) + val popNC = pop.count(_.Faction == PlanetSideEmpire.NC) + val popVS = pop.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/csr/GeneralLogic.scala b/src/main/scala/net/psforever/actors/session/csr/GeneralLogic.scala new file mode 100644 index 000000000..61c5e942a --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/csr/GeneralLogic.scala @@ -0,0 +1,1540 @@ +// Copyright (c) 2024 PSForever +package net.psforever.actors.session.csr + +import akka.actor.typed.scaladsl.adapter._ +import akka.actor.{ActorContext, ActorRef, typed} +import net.psforever.actors.session.{AvatarActor, SessionActor} +import net.psforever.actors.session.support.{GeneralFunctions, GeneralOperations, SessionData} +import net.psforever.login.WorldSession.{CallBackForTask, ContainableMoveItem, DropEquipmentFromInventory, PickUpEquipmentFromGround, 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.objects.avatar.{Avatar, PlayerControl, SpecialCarry} +import net.psforever.objects.ballistics.Projectile +import net.psforever.objects.ce.{Deployable, DeployedItem, TelepadLike} +import net.psforever.objects.definition.{BasicDefinition, KitDefinition, SpecialExoSuitDefinition} +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 +import net.psforever.objects.serverobject.{CommonMessages, PlanetSideServerObject, ServerObject} +import net.psforever.objects.serverobject.affinity.FactionAffinity +import net.psforever.objects.serverobject.containable.Containable +import net.psforever.objects.serverobject.doors.Door +import net.psforever.objects.serverobject.generator.Generator +import net.psforever.objects.serverobject.llu.CaptureFlag +import net.psforever.objects.serverobject.locks.IFFLock +import net.psforever.objects.serverobject.mblocker.Locker +import net.psforever.objects.serverobject.resourcesilo.ResourceSilo +import net.psforever.objects.serverobject.structures.{Building, WarpGate} +import net.psforever.objects.serverobject.terminals.capture.CaptureTerminal +import net.psforever.objects.serverobject.terminals.{MatrixTerminalDefinition, ProximityUnit, Terminal} +import net.psforever.objects.serverobject.terminals.implant.ImplantTerminalMech +import net.psforever.objects.serverobject.tube.SpawnTube +import net.psforever.objects.serverobject.turret.FacilityTurret +import net.psforever.objects.sourcing.{PlayerSource, SourceEntry, VehicleSource} +import net.psforever.objects.vehicles.{AccessPermissionGroup, Utility, UtilityType, VehicleLockState} +import net.psforever.objects.vehicles.Utility.InternalTelepad +import net.psforever.objects.vital.{VehicleDismountActivity, VehicleMountActivity, Vitality} +import net.psforever.objects.vital.collision.{CollisionReason, CollisionWithReason} +import net.psforever.objects.vital.etc.SuicideReason +import net.psforever.objects.vital.interaction.DamageInteraction +import net.psforever.objects.zones.blockmap.BlockMapEntity +import net.psforever.objects.zones.{Zone, ZoneProjectile, Zoning} +import net.psforever.packet.PlanetSideGamePacket +import net.psforever.packet.game.objectcreate.ObjectClass +import net.psforever.packet.game.{ActionCancelMessage, ActionResultMessage, AvatarFirstTimeEventMessage, AvatarImplantMessage, AvatarJumpMessage, BattleplanMessage, BindPlayerMessage, BindStatus, BugReportMessage, ChangeFireModeMessage, ChangeShortcutBankMessage, CharacterCreateRequestMessage, CharacterRequestAction, CharacterRequestMessage, ChatMsg, CollisionIs, ConnectToWorldRequestMessage, CreateShortcutMessage, DeadState, DeployObjectMessage, DisplayedAwardMessage, DropItemMessage, EmoteMsg, FacilityBenefitShieldChargeRequestMessage, FriendsRequest, GenericAction, GenericActionMessage, GenericCollisionMsg, GenericObjectActionAtPositionMessage, GenericObjectActionMessage, GenericObjectStateMsg, HitHint, ImplantAction, InvalidTerrainMessage, ItemTransactionMessage, LootItemMessage, MoveItemMessage, ObjectDeleteMessage, ObjectDetectedMessage, ObjectHeldMessage, PickupItemMessage, PlanetsideAttributeMessage, PlayerStateMessageUpstream, PlayerStateShiftMessage, RequestDestroyMessage, ShiftState, TargetInfo, TargetingImplantRequest, TargetingInfoMessage, TerrainCondition, TradeMessage, UnuseItemMessage, UseItemMessage, VoiceHostInfo, VoiceHostRequest, ZipLineMessage} +import net.psforever.services.RemoverActor +import net.psforever.services.account.{AccountPersistenceService, RetrieveAccountData} +import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage} +import net.psforever.services.local.{LocalAction, LocalServiceMessage} +import net.psforever.services.local.support.CaptureFlagManager +import net.psforever.types.{CapacitorStateType, ChatMessageType, Cosmetic, DriveState, ExoSuitType, ImplantType, PlanetSideEmpire, PlanetSideGUID, SpawnGroup, TransactionType, Vector3} +import net.psforever.util.Config + +import scala.concurrent.duration._ + +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 + + def handleConnectToWorldRequest(pkt: ConnectToWorldRequestMessage): Unit = { + val ConnectToWorldRequestMessage(_, token, majorVersion, minorVersion, revision, buildDate, _, _) = pkt + log.trace( + 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) + } + + def handleCharacterCreateRequest(pkt: CharacterCreateRequestMessage): Unit = { + val CharacterCreateRequestMessage(name, head, voice, gender, empire) = pkt + avatarActor ! AvatarActor.CreateAvatar(name, head, voice, gender, empire) + } + + def handleCharacterRequest(pkt: CharacterRequestMessage): Unit = { + val CharacterRequestMessage(charId, action) = pkt + action match { + case CharacterRequestAction.Delete => + avatarActor ! AvatarActor.DeleteAvatar(charId.toInt) + case CharacterRequestAction.Select => + avatarActor ! AvatarActor.SelectAvatar(charId.toInt, context.self) + } + } + + def handlePlayerStateUpstream(pkt: PlayerStateMessageUpstream): Unit = { + val PlayerStateMessageUpstream( + avatarGuid, + pos, + vel, + yaw, + pitch, + yawUpper, + seqTime, + _, + isCrouching, + isJumping, + jumpThrust, + isCloaking, + _, + _ + ) = pkt + sessionLogic.persist() + sessionLogic.turnCounterFunc(avatarGuid) + sessionLogic.updateBlockMap(player, pos) + //below half health, fully heal + val maxHealth = player.MaxHealth.toLong + if (player.Health < maxHealth * 0.5) { + player.Health = maxHealth.toInt + sendResponse(PlanetsideAttributeMessage(avatarGuid, 0, maxHealth)) + continent.AvatarEvents ! AvatarServiceMessage(continent.id, AvatarAction.PlanetsideAttribute(avatarGuid, 0, maxHealth)) + } + //expected + val isMoving = WorldEntity.isMoving(vel) + val isMovingPlus = isMoving || isJumping || jumpThrust + if (isMovingPlus) { + if (sessionLogic.zoning.zoningStatus == Zoning.Status.Deconstructing) { + sessionLogic.zoning.spawn.stopDeconstructing() + } else if (sessionLogic.zoning.zoningStatus != Zoning.Status.None) { + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_motion") + } + } + 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 + if (isCloaking && !player.Cloaked) { + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_cloak") + } + player.Cloaked = player.ExoSuit == ExoSuitType.Infiltration && isCloaking + maxCapacitorTick(jumpThrust) + if (isMovingPlus && sessionLogic.terminals.usingMedicalTerminal.isDefined) { + continent.GUID(sessionLogic.terminals.usingMedicalTerminal) match { + case Some(term: Terminal with ProximityUnit) => + sessionLogic.terminals.StopUsingProximityUnit(term) + case _ => () + } + } + ops.accessedContainer match { + // Ensure we don't unload the contents of the vehicle trunk for players seated in the vehicle. + // This can happen if PSUM arrives during the mounting process + case Some(veh: Vehicle) if player.VehicleSeated.isEmpty || player.VehicleSeated.get != veh.GUID => + if (isMoving || veh.isMoving(test = 1) || Vector3.DistanceSquared(player.Position, veh.TrunkLocation) > 9) { + val guid = player.GUID + sendResponse(UnuseItemMessage(guid, veh.GUID)) + sendResponse(UnuseItemMessage(guid, guid)) + ops.unaccessContainer(veh) + } + case Some(container) => //just in case + if (isMovingPlus && (player.VehicleSeated.isEmpty || player.VehicleSeated.get != container.GUID)) { + // Ensure we don't close the container if the player is seated in it + val guid = player.GUID + // 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(guid, container.GUID)) + } + sendResponse(UnuseItemMessage(guid, guid)) + ops.unaccessContainer(container) + } + case None => () + } + val eagleEye: Boolean = ops.canSeeReallyFar + val isNotVisible: Boolean = sessionLogic.zoning.zoningStatus == Zoning.Status.Deconstructing || + (player.isAlive && sessionLogic.zoning.spawn.deadState == DeadState.RespawnTime) + continent.AvatarEvents ! AvatarServiceMessage( + continent.id, + AvatarAction.PlayerState( + avatarGuid, + player.Position, + player.Velocity, + yaw, + pitch, + yawUpper, + seqTime, + isCrouching, + isJumping, + jumpThrust, + isCloaking, + isNotVisible, + eagleEye + ) + ) + sessionLogic.squad.updateSquad() + if (player.death_by == -1) { + sessionLogic.kickedByAdministration() + } + player.zoneInteractions() + } + + def handleVoiceHostRequest(pkt: VoiceHostRequest): Unit = { + ops.noVoicedChat(pkt) + } + + def handleVoiceHostInfo(pkt: VoiceHostInfo): Unit = { + ops.noVoicedChat(pkt) + } + + def handleEmote(pkt: EmoteMsg): Unit = { + val EmoteMsg(avatarGuid, emote) = pkt + sendResponse(EmoteMsg(avatarGuid, emote)) + } + + def handleDropItem(pkt: DropItemMessage): Unit = { + val DropItemMessage(itemGuid) = pkt + (sessionLogic.validObject(itemGuid, decorator = "DropItem"), player.FreeHand.Equipment) match { + case (Some(anItem: Equipment), Some(heldItem)) + if (anItem eq heldItem) && continent.GUID(player.VehicleSeated).nonEmpty => + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_use") + RemoveOldEquipmentFromInventory(player)(heldItem) + case (Some(anItem: Equipment), Some(heldItem)) + if anItem eq heldItem => + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_use") + DropEquipmentFromInventory(player)(heldItem) + case (Some(anItem: Equipment), _) + if continent.GUID(player.VehicleSeated).isEmpty => + //suppress the warning message if in a vehicle + log.warn(s"DropItem: ${player.Name} wanted to drop a ${anItem.Definition.Name}, but it wasn't at hand") + case (Some(obj), _) => + log.warn(s"DropItem: ${player.Name} wanted to drop a ${obj.Definition.Name}, but it was not equipment") + case _ => () + } + } + + def handlePickupItem(pkt: PickupItemMessage): Unit = { + val PickupItemMessage(itemGuid, _, _, _) = pkt + sessionLogic.validObject(itemGuid, decorator = "PickupItem").collect { + case item: Equipment if player.Fit(item).nonEmpty => + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_use") + PickUpEquipmentFromGround(player)(item) + case _: Equipment => + sendResponse(ActionResultMessage.Fail(16)) //error code? + } + } + + def handleObjectHeld(pkt: ObjectHeldMessage): Unit = { + val ObjectHeldMessage(_, heldHolsters, _) = pkt + player.Actor ! PlayerControl.ObjectHeld(heldHolsters) + } + + def handleAvatarJump(pkt: AvatarJumpMessage): Unit = { + val AvatarJumpMessage(_) = pkt + avatarActor ! AvatarActor.ConsumeStamina(10) + avatarActor ! AvatarActor.SuspendStaminaRegeneration(2.5 seconds) + } + + 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 => + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel") + 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(_) => + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_motion") + 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(vehicle: Vehicle) => + /* vehicle is not mounted in anything or, if it is, its seats are empty */ + if (vehicle.MountedIn.isEmpty || !vehicle.Seats.values.exists(_.isOccupied)) { //todo kick out to delete? + vehicle.Actor ! Vehicle.Deconstruct() + //log.info(s"RequestDestroy: vehicle $vehicle") + } else { + log.warn(s"RequestDestroy: ${player.Name} must own vehicle in order to deconstruct it") + } + + 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 Some(obj: Deployable) => + obj.Actor ! Deployable.Deconstruct() + + case Some(obj: Equipment) => + findEquipmentToDelete(objectGuid, obj) + + case Some(thing) => + log.warn(s"RequestDestroy: not allowed to delete this ${thing.Definition.Name}") + + case None => () + } + } + + def handleMoveItem(pkt: MoveItemMessage): Unit = { + val MoveItemMessage(itemGuid, sourceGuid, destinationGuid, dest, _) = pkt + ( + continent.GUID(sourceGuid), + continent.GUID(destinationGuid), + sessionLogic.validObject(itemGuid, decorator = "MoveItem") + ) match { + case ( + Some(source: PlanetSideServerObject with Container), + Some(destination: PlanetSideServerObject with Container), + Some(item: Equipment) + ) => + ContainableMoveItem(player.Name, source, destination, item, destination.SlotMapResolution(dest)) + case (None, _, _) => + log.error( + s"MoveItem: ${player.Name} wanted to move $itemGuid from $sourceGuid, but could not find source object" + ) + case (_, None, _) => + log.error( + s"MoveItem: ${player.Name} wanted to move $itemGuid to $destinationGuid, but could not find destination object" + ) + case (_, _, None) => () + case _ => + log.error( + s"MoveItem: ${player.Name} wanted to move $itemGuid from $sourceGuid to $destinationGuid, but multiple problems were encountered" + ) + } + } + + def handleLootItem(pkt: LootItemMessage): Unit = { + val LootItemMessage(itemGuid, targetGuid) = pkt + (sessionLogic.validObject(itemGuid, decorator = "LootItem"), continent.GUID(targetGuid)) match { + case (Some(item: Equipment), Some(destination: PlanetSideServerObject with Container)) => + //figure out the source + ( + { + val findFunc: PlanetSideServerObject with Container => Option[ + (PlanetSideServerObject with Container, Option[Int]) + ] = ops.findInLocalContainer(itemGuid) + findFunc(player.avatar.locker) + .orElse(findFunc(player)) + .orElse(ops.accessedContainer match { + case Some(parent: PlanetSideServerObject) => + findFunc(parent) + case _ => + None + }) + }, + destination.Fit(item) + ) match { + case (Some((source, Some(_))), Some(dest)) => + ContainableMoveItem(player.Name, source, destination, item, dest) + case (None, _) => + log.error(s"LootItem: ${player.Name} can not find where $item is put currently") + case (_, None) => + log.error(s"LootItem: ${player.Name} can not find anywhere to put $item in $destination") + case _ => + log.error( + s"LootItem: ${player.Name}wanted to move $itemGuid to $targetGuid, but multiple problems were encountered" + ) + } + case (Some(obj), _) => + log.error(s"LootItem: item $obj is (probably) not lootable to ${player.Name}") + case (None, _) => () + case (_, None) => + log.error(s"LootItem: ${player.Name} can not find where to put $itemGuid") + } + } + + def handleAvatarImplant(pkt: AvatarImplantMessage): Unit = { + val AvatarImplantMessage(_, action, slot, status) = pkt + if (action == ImplantAction.Activation) { + if (sessionLogic.zoning.zoningStatus == Zoning.Status.Deconstructing) { + //do not activate; play deactivation sound instead + sessionLogic.zoning.spawn.stopDeconstructing() + avatar.implants(slot).collect { + case implant if implant.active => + avatarActor ! AvatarActor.DeactivateImplant(implant.definition.implantType) + case implant => + sendResponse(PlanetsideAttributeMessage(player.GUID, 28, implant.definition.implantType.value * 2)) + } + } else { + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_implant") + avatar.implants(slot) match { + case Some(implant) => + if (status == 1) { + avatarActor ! AvatarActor.ActivateImplant(implant.definition.implantType) + } else { + avatarActor ! AvatarActor.DeactivateImplant(implant.definition.implantType) + } + case _ => + log.error(s"AvatarImplantMessage: ${player.Name} has an unknown implant in $slot") + } + } + } + } + + def handleUseItem(pkt: UseItemMessage): Unit = { + val equipment = ops.findContainedEquipment(pkt.item_used_guid) match { + case (o @ Some(_), a) if a.exists(_.isInstanceOf[Tool]) => + sessionLogic.shooting.FindEnabledWeaponsToHandleWeaponFireAccountability(o, a.collect { case w: Tool => w })._2.headOption + case (Some(_), a) => + a.headOption + case _ => + None + } + sessionLogic.validObject(pkt.object_guid, decorator = "UseItem") match { + case Some(door: Door) => + handleUseDoor(door, equipment) + case Some(resourceSilo: ResourceSilo) => + handleUseResourceSilo(resourceSilo, equipment) + case Some(panel: IFFLock) => + handleUseGeneralEntity(panel, equipment) + case Some(obj: Player) => + handleUsePlayer(obj, equipment, pkt) + case Some(locker: Locker) => + handleUseLocker(locker, equipment, pkt) + case Some(gen: Generator) => + handleUseGeneralEntity(gen, equipment) + case Some(mech: ImplantTerminalMech) => + handleUseGeneralEntity(mech, equipment) + case Some(captureTerminal: CaptureTerminal) => + handleUseCaptureTerminal(captureTerminal, equipment) + case Some(obj: FacilityTurret) => + handleUseFacilityTurret(obj, equipment, pkt) + case Some(obj: Vehicle) => + handleUseVehicle(obj, equipment, pkt) + case Some(terminal: Terminal) => + handleUseTerminal(terminal, equipment, pkt) + case Some(obj: SpawnTube) => + handleUseSpawnTube(obj, equipment) + case Some(obj: SensorDeployable) => + handleUseGeneralEntity(obj, equipment) + case Some(obj: TurretDeployable) => + handleUseGeneralEntity(obj, equipment) + case Some(obj: TrapDeployable) => + handleUseGeneralEntity(obj, equipment) + case Some(obj: ShieldGeneratorDeployable) => + handleUseGeneralEntity(obj, equipment) + case Some(obj: TelepadDeployable) => + handleUseTelepadDeployable(obj, equipment, pkt) + case Some(obj: Utility.InternalTelepad) => + handleUseInternalTelepad(obj, pkt) + case Some(obj: CaptureFlag) => + handleUseCaptureFlag(obj) + case Some(_: WarpGate) => + handleUseWarpGate(equipment) + case Some(obj) => + handleUseDefaultEntity(obj, equipment) + case None => () + } + } + + def handleUnuseItem(pkt: UnuseItemMessage): Unit = { + val UnuseItemMessage(_, objectGuid) = pkt + sessionLogic.validObject(objectGuid, decorator = "UnuseItem") match { + case Some(obj: Player) => + ops.unaccessContainer(obj) + sessionLogic.zoning.spawn.TryDisposeOfLootedCorpse(obj) + case Some(obj: Container) => + // Make sure we don't unload the contents of the vehicle the player is seated in + // An example scenario of this would be closing the trunk contents when rearming at a landing pad + if (player.VehicleSeated.isEmpty || player.VehicleSeated.get != obj.GUID) { + ops.unaccessContainer(obj) + } + case _ => () + } + } + + def handleDeployObject(pkt: DeployObjectMessage): Unit = { + val DeployObjectMessage(guid, _, pos, orient, _) = pkt + player.Holsters().find(slot => slot.Equipment.nonEmpty && slot.Equipment.get.GUID == guid).flatMap { slot => slot.Equipment } match { + case Some(obj: ConstructionItem) => + val ammoType = obj.AmmoType match { + case DeployedItem.portable_manned_turret => GlobalDefinitions.PortableMannedTurret(player.Faction).Item + case dtype => dtype + } + log.info(s"${player.Name} is constructing a $ammoType deployable") + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_use") + val dObj: Deployable = Deployables.Make(ammoType)() + dObj.Position = pos + dObj.Orientation = orient + dObj.WhichSide = player.WhichSide + dObj.Faction = player.Faction + dObj.AssignOwnership(player) + val tasking: TaskBundle = dObj match { + case turret: TurretDeployable => + GUIDTask.registerDeployableTurret(continent.GUID, turret) + case _ => + GUIDTask.registerObject(continent.GUID, dObj) + } + TaskWorkflow.execute(CallBackForTask(tasking, continent.Deployables, Zone.Deployable.BuildByOwner(dObj, player, obj), context.self)) + case Some(obj) => + log.warn(s"DeployObject: what is $obj, ${player.Name}? It's not a construction tool!") + case None => + log.error(s"DeployObject: nothing, ${player.Name}? It's not a construction tool!") + } + } + + def handlePlanetsideAttribute(pkt: PlanetsideAttributeMessage): Unit = { + val PlanetsideAttributeMessage(objectGuid, attributeType, attributeValue) = pkt + sessionLogic.validObject(objectGuid, decorator = "PlanetsideAttribute") match { + case Some(vehicle: Vehicle) if player.avatar.vehicle.contains(vehicle.GUID) => + vehicle.Actor ! ServerObject.AttributeMsg(attributeType, attributeValue) + case Some(vehicle: Vehicle) => + log.warn(s"PlanetsideAttribute: ${player.Name} does not own vehicle ${vehicle.GUID} and can not change it") + // Cosmetics options + case Some(_: Player) if attributeType == 106 => + avatarActor ! AvatarActor.SetCosmetics(Cosmetic.valuesFromAttributeValue(attributeValue)) + 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: Vehicle) + if vehicle.OwnerName.contains(player.Name) => + vehicle.Actor ! ServerObject.GenericObjectAction(objectGuid, code, Some(player.GUID)) + + case Some(tool: Tool) => + if (code == 35 && + (tool.Definition == GlobalDefinitions.maelstrom || tool.Definition.Name.startsWith("aphelion_laser")) + ) { + //maelstrom primary fire mode discharge (no target) + //aphelion_laser discharge (no target) + sessionLogic.shooting.HandleWeaponFireAccountability(objectGuid, PlanetSideGUID(Projectile.baseUID)) + } else { + sessionLogic.validObject(player.VehicleSeated, decorator = "GenericObjectAction/Vehicle") collect { + case vehicle: Vehicle + if vehicle.OwnerName.contains(player.Name) => + vehicle.Actor ! ServerObject.GenericObjectAction(objectGuid, code, 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) => + sessionLogic.shooting.FindContainedWeapon match { + case (Some(vehicle: Vehicle), weps) if weps.exists(_.GUID == objectGuid) => + vehicle.Actor ! SpecialEmp.Burst() + case _ => () + } + 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 + if (player == null) { + if (action == GenericAction.AwayFromKeyboard_RCV) { + log.debug("GenericObjectState: AFK state reported during login") + } + } else { + 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.DropSpecialItem => + ops.dropSpecialSlotItem() + 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.MaxSpecialEffect_RCV => + if (player.ExoSuit == ExoSuitType.MAX) { + ops.toggleMaxSpecialState(enable = true) + } else { + log.warn(s"GenericActionMessage: ${player.Name} can't handle MAX special effect") + } + case GenericAction.StopMaxSpecialEffect_RCV => + if (player.ExoSuit == ExoSuitType.MAX) { + player.Faction match { + case PlanetSideEmpire.NC => + ops.toggleMaxSpecialState(enable = false) + case _ => + log.warn(s"GenericActionMessage: ${player.Name} tried to cancel an uncancellable MAX special ability") + } + } else { + log.warn(s"GenericActionMessage: ${player.Name} can't stop MAX special effect") + } + 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 = { + val GenericCollisionMsg(ctype, p, _, ppos, pv, t, _, tpos, tv, _, _, _) = pkt + val fallHeight = { + if (pv.z * pv.z >= (pv.x * pv.x + pv.y * pv.y) * 0.5f) { + if (ops.heightTrend) { + val fall = ops.heightLast - ops.heightHistory + ops.heightHistory = ops.heightLast + fall + } + else { + val fall = ops.heightHistory - ops.heightLast + ops.heightLast = ops.heightHistory + fall + } + } else { + 0f + } + } + val (target1, target2, bailProtectStatus, velocity) = (ctype, sessionLogic.validObject(p, decorator = "GenericCollision/Primary")) match { + case (CollisionIs.OfInfantry, out @ Some(user: Player)) + if user == player => + val bailStatus = session.flying || session.speed > 1f || player.BailProtection + player.BailProtection = false + val v = if (player.avatar.implants.exists { + case Some(implant) => implant.definition.implantType == ImplantType.Surge && implant.active + case _ => false + }) { + Vector3.Zero + } else { + pv + } + (out, None, bailStatus, v) + case (CollisionIs.OfGroundVehicle, out @ Some(v: Vehicle)) + if v.Seats(0).occupant.contains(player) => + val bailStatus = v.BailProtection + v.BailProtection = false + (out, sessionLogic.validObject(t, decorator = "GenericCollision/GroundVehicle"), bailStatus, pv) + case (CollisionIs.OfAircraft, out @ Some(v: Vehicle)) + if v.Definition.CanFly && v.Seats(0).occupant.contains(player) => + (out, sessionLogic.validObject(t, decorator = "GenericCollision/Aircraft"), false, pv) + case (CollisionIs.BetweenThings, _) => + log.warn("GenericCollision: CollisionIs.BetweenThings detected - no handling case") + (None, None, false, Vector3.Zero) + case _ => + (None, None, false, Vector3.Zero) + } + val curr = System.currentTimeMillis() + (target1, t, target2) match { + case (None, _, _) => () + + case (Some(us: PlanetSideServerObject with Vitality with FactionAffinity), PlanetSideGUID(0), _) => + if (updateCollisionHistoryForTarget(us, curr)) { + if (!bailProtectStatus) { + sessionLogic.handleDealingDamage( + us, + DamageInteraction( + SourceEntry(us), + CollisionReason(velocity, fallHeight, us.DamageModel), + ppos + ) + ) + } + } + + case (Some(us: Vehicle), _, Some(victim: SensorDeployable)) => + collisionBetweenVehicleAndFragileDeployable(us, ppos, victim, tpos, velocity - tv, fallHeight, curr) + + case (Some(us: Vehicle), _, Some(victim: TurretDeployable)) if victim.Seats.isEmpty => + collisionBetweenVehicleAndFragileDeployable(us, ppos, victim, tpos, velocity - tv, fallHeight, curr) + + case ( + Some(us: PlanetSideServerObject with Vitality with FactionAffinity), _, + Some(victim: PlanetSideServerObject with Vitality with FactionAffinity) + ) => + if (updateCollisionHistoryForTarget(victim, curr)) { + val usSource = SourceEntry(us) + val victimSource = SourceEntry(victim) + //we take damage from the collision + if (!bailProtectStatus) { + performCollisionWithSomethingDamage(us, usSource, ppos, victimSource, fallHeight, velocity - tv) + } + //get dealt damage from our own collision (no protection) + ops.collisionHistory.put(us.Actor, curr) + performCollisionWithSomethingDamage(victim, victimSource, tpos, usSource, fallHeight = 0f, tv - velocity) + } + + case _ => () + } + } + + def handleAvatarFirstTimeEvent(pkt: AvatarFirstTimeEventMessage): Unit = { + val AvatarFirstTimeEventMessage(_, _, _, eventName) = pkt + avatarActor ! AvatarActor.AddFirstTimeEvent(eventName) + } + + 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 = { + val FacilityBenefitShieldChargeRequestMessage(_) = pkt + val vehicleGuid = player.VehicleSeated + continent + .GUID(vehicleGuid) + .foreach { + case obj: Vitality if obj.Destroyed => () //some entities will try to charge even if destroyed + case obj: Vehicle if obj.MountedIn.nonEmpty => () //cargo vehicles need to be excluded + case obj: Vehicle => + commonFacilityShieldCharging(obj) + case obj: TurretDeployable => + commonFacilityShieldCharging(obj) + case _ if vehicleGuid.nonEmpty => + log.warn( + s"FacilityBenefitShieldChargeRequest: ${player.Name} can not find chargeable entity ${vehicleGuid.get.guid} in ${continent.id}" + ) + case _ => + log.warn(s"FacilityBenefitShieldChargeRequest: ${player.Name} is not seated in anything") + } + } + + 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 = { + val CreateShortcutMessage(_, slot, shortcutOpt) = pkt + shortcutOpt match { + case Some(shortcut) => + avatarActor ! AvatarActor.AddShortcut(slot - 1, shortcut) + case None => + avatarActor ! AvatarActor.RemoveShortcut(slot - 1) + } + } + + def handleChangeShortcutBank(pkt: ChangeShortcutBankMessage): Unit = { + val ChangeShortcutBankMessage(_, _) = pkt + } + + def handleFriendRequest(pkt: FriendsRequest): Unit = { + val FriendsRequest(action, name) = pkt + avatarActor ! AvatarActor.MemberListRequest(action, name) + } + + def handleInvalidTerrain(pkt: InvalidTerrainMessage): Unit = { + val InvalidTerrainMessage(_, vehicleGuid, alert, _) = pkt + (continent.GUID(vehicleGuid), continent.GUID(player.VehicleSeated)) match { + case (Some(packetVehicle: Vehicle), Some(playerVehicle: Vehicle)) if packetVehicle eq playerVehicle => + if (alert == TerrainCondition.Unsafe) { + log.info(s"${player.Name}'s ${packetVehicle.Definition.Name} is approaching terrain unsuitable for idling") + } + case (Some(packetVehicle: Vehicle), Some(_: Vehicle)) => + if (alert == TerrainCondition.Unsafe) { + log.info(s"${packetVehicle.Definition.Name}@${packetVehicle.GUID} is approaching terrain unsuitable for idling, but is not ${player.Name}'s vehicle") + } + case (Some(_: Vehicle), _) => + log.warn(s"InvalidTerrain: ${player.Name} is not seated in a(ny) vehicle near unsuitable terrain") + case (Some(packetThing), _) => + log.warn(s"InvalidTerrain: ${player.Name} thinks that ${packetThing.Definition.Name}@${packetThing.GUID} is near unsuitable terrain") + case _ => + log.error(s"InvalidTerrain: ${player.Name} is complaining about a thing@$vehicleGuid that can not be found") + } + } + + def handleActionCancel(pkt: ActionCancelMessage): Unit = { + val ActionCancelMessage(_, _, _) = pkt + ops.progressBarUpdate.cancel() + ops.progressBarValue = None + } + + def handleTrade(pkt: TradeMessage): Unit = { + val TradeMessage(trade) = pkt + log.trace(s"${player.Name} wants to trade for some reason - $trade") + } + + 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 = { + val ObjectDetectedMessage(_, _, _, targets) = pkt + sessionLogic.shooting.FindWeapon.foreach { + case weapon if weapon.Projectile.AutoLock => + //projectile with auto-lock instigates a warning on the target + val detectedTargets = sessionLogic.shooting.FindDetectedProjectileTargets(targets) + val mode = 7 + (if (weapon.Projectile == GlobalDefinitions.wasp_rocket_projectile) 1 else 0) + detectedTargets.foreach { target => + continent.AvatarEvents ! AvatarServiceMessage(target, AvatarAction.ProjectileAutoLockAwareness(mode)) + } + case _ => () + } + } + + 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 = { + val HitHint(_, _) = pkt + } + + /* messages */ + + def handleRenewCharSavedTimer(): Unit = { + ops.renewCharSavedTimer( + Config.app.game.savedMsg.interruptedByAction.fixed, + Config.app.game.savedMsg.interruptedByAction.variable + ) + } + + def handleRenewCharSavedTimerMsg(): Unit = { + ops.displayCharSavedMsgThenRenewTimer( + Config.app.game.savedMsg.interruptedByAction.fixed, + Config.app.game.savedMsg.interruptedByAction.variable + ) + } + + 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 = { + ops.administrativeKick(player) + sessionLogic.accountPersistence ! AccountPersistenceService.Kick(player.Name, time) + } + + def handleSilenced(isSilenced: Boolean): Unit = { + player.silenced = isSilenced + } + + def handleItemPutInSlot(msg: Containable.ItemPutInSlot): Unit = { + log.debug(s"ItemPutInSlot: $msg") + } + + def handleCanNotPutItemInSlot(msg: Containable.CanNotPutItemInSlot): Unit = { + log.debug(s"CanNotPutItemInSlot: $msg") + } + + def handleReceiveDefaultMessage(default: Any, sender: ActorRef): Unit = { + log.warn(s"Invalid packet class received: $default from $sender") + } + + /* 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 handleUseResourceSilo(resourceSilo: ResourceSilo, equipment: Option[Equipment]): Unit = { + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_use") + val vehicleOpt = continent.GUID(player.avatar.vehicle) + (vehicleOpt, equipment) match { + case (Some(vehicle: Vehicle), Some(item)) + if GlobalDefinitions.isBattleFrameVehicle(vehicle.Definition) && + GlobalDefinitions.isBattleFrameNTUSiphon(item.Definition) => + resourceSilo.Actor ! CommonMessages.Use(player, Some(vehicle)) + case (Some(vehicle: Vehicle), _) + if vehicle.Definition == GlobalDefinitions.ant && + vehicle.DeploymentState == DriveState.Deployed && + Vector3.DistanceSquared(resourceSilo.Position.xy, vehicle.Position.xy) < math.pow(resourceSilo.Definition.UseRadius, 2) => + resourceSilo.Actor ! CommonMessages.Use(player, Some(vehicle)) + case _ => () + } + } + + private def handleUsePlayer(obj: Player, equipment: Option[Equipment], msg: UseItemMessage): Unit = { + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_use") + if (obj.isBackpack) { + if (equipment.isEmpty) { + log.info(s"${player.Name} is looting the corpse of ${obj.Name}") + sendResponse(msg) + ops.accessContainer(obj) + } + } else if (!msg.unk3 && player.isAlive) { //potential kit use + (continent.GUID(msg.item_used_guid), ops.kitToBeUsed) match { + case (Some(kit: Kit), None) => + ops.kitToBeUsed = Some(msg.item_used_guid) + player.Actor ! CommonMessages.Use(player, Some(kit)) + case (Some(_: Kit), Some(_)) | (None, Some(_)) => + //a kit is already queued to be used; ignore this request + sendResponse(ChatMsg(ChatMessageType.UNK_225, wideContents=false, "", "Please wait ...", None)) + case (Some(item), _) => + log.error(s"UseItem: ${player.Name} looking for Kit to use, but found $item instead") + case (None, None) => + log.warn(s"UseItem: anticipated a Kit ${msg.item_used_guid} for ${player.Name}, but can't find it") } + } else if (msg.object_id == ObjectClass.avatar && msg.unk3) { + equipment match { + case Some(tool: Tool) if tool.Definition == GlobalDefinitions.bank => + obj.Actor ! CommonMessages.Use(player, equipment) + + case Some(tool: Tool) if tool.Definition == GlobalDefinitions.medicalapplicator => + obj.Actor ! CommonMessages.Use(player, equipment) + case _ => () + } + } + } + + private def handleUseLocker(locker: Locker, equipment: Option[Equipment], msg: UseItemMessage): Unit = { + equipment match { + case Some(item) => + sendUseGeneralEntityMessage(locker, item) + case None if locker.Faction == player.Faction || locker.HackedBy.nonEmpty => + log.info(s"${player.Name} is accessing a locker") + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_use") + val playerLocker = player.avatar.locker + sendResponse(msg.copy(object_guid = playerLocker.GUID, object_id = 456)) + ops.accessContainer(playerLocker) + case _ => () + } + } + + private def handleUseCaptureTerminal(captureTerminal: CaptureTerminal, equipment: Option[Equipment]): Unit = { + equipment match { + case Some(item) => + sendUseGeneralEntityMessage(captureTerminal, item) + case _ if ops.specialItemSlotGuid.nonEmpty => + continent.GUID(ops.specialItemSlotGuid) match { + case Some(llu: CaptureFlag) => + if (llu.Target.GUID == captureTerminal.Owner.GUID) { + continent.LocalEvents ! LocalServiceMessage(continent.id, LocalAction.LluCaptured(llu)) + } else { + log.info( + s"LLU target is not this base. Target GUID: ${llu.Target.GUID} This base: ${captureTerminal.Owner.GUID}" + ) + } + case _ => log.warn("Item in specialItemSlotGuid is not registered with continent or is not a LLU") + } + case _ => () + } + } + + private def handleUseFacilityTurret(obj: FacilityTurret, equipment: Option[Equipment], msg: UseItemMessage): Unit = { + equipment.foreach { item => + sendUseGeneralEntityMessage(obj, item) + obj.Actor ! CommonMessages.Use(player, Some((item, msg.unk2.toInt))) //try upgrade path + } + } + + private def handleUseVehicle(obj: Vehicle, equipment: Option[Equipment], msg: UseItemMessage): Unit = { + equipment match { + case Some(item) => + sendUseGeneralEntityMessage(obj, item) + case None if player.Faction == obj.Faction => + //access to trunk + if ( + obj.AccessingTrunk.isEmpty && + (!obj.PermissionGroup(AccessPermissionGroup.Trunk.id).contains(VehicleLockState.Locked) || obj.OwnerGuid + .contains(player.GUID)) + ) { + log.info(s"${player.Name} is looking in the ${obj.Definition.Name}'s trunk") + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_use") + obj.AccessingTrunk = player.GUID + ops.accessContainer(obj) + sendResponse(msg) + } + case _ => () + } + } + + private def handleUseTerminal(terminal: Terminal, equipment: Option[Equipment], msg: UseItemMessage): Unit = { + equipment match { + case Some(item) => + sendUseGeneralEntityMessage(terminal, item) + case None + if terminal.Owner == Building.NoBuilding || terminal.Faction == player.Faction || + terminal.HackedBy.nonEmpty || terminal.Faction == PlanetSideEmpire.NEUTRAL => + val tdef = terminal.Definition + if (tdef.isInstanceOf[MatrixTerminalDefinition]) { + //TODO matrix spawn point; for now, just blindly bind to show work (and hope nothing breaks) + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_use") + sendResponse( + BindPlayerMessage(BindStatus.Bind, "", display_icon=true, logging=true, SpawnGroup.Sanctuary, 0, 0, terminal.Position) + ) + } else if ( + tdef == GlobalDefinitions.multivehicle_rearm_terminal || tdef == GlobalDefinitions.bfr_rearm_terminal || + tdef == GlobalDefinitions.air_rearm_terminal || tdef == GlobalDefinitions.ground_rearm_terminal + ) { + findLocalVehicle match { + case Some(vehicle) => + log.info( + s"${player.Name} is accessing a ${terminal.Definition.Name} for ${player.Sex.possessive} ${vehicle.Definition.Name}" + ) + sendResponse(msg) + sendResponse(msg.copy(object_guid = vehicle.GUID, object_id = vehicle.Definition.ObjectId)) + case None => + log.error(s"UseItem: Expecting a seated vehicle, ${player.Name} found none") + } + } else if (tdef == GlobalDefinitions.teleportpad_terminal) { + //explicit request + log.info(s"${player.Name} is purchasing a router telepad") + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_use") + terminal.Actor ! Terminal.Request( + player, + ItemTransactionMessage(msg.object_guid, TransactionType.Buy, 0, "router_telepad", 0, PlanetSideGUID(0)) + ) + } else if (tdef == GlobalDefinitions.targeting_laser_dispenser) { + //explicit request + log.info(s"${player.Name} is purchasing a targeting laser") + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_use") + terminal.Actor ! Terminal.Request( + player, + ItemTransactionMessage(msg.object_guid, TransactionType.Buy, 0, "flail_targeting_laser", 0, PlanetSideGUID(0)) + ) + } else { + log.info(s"${player.Name} is accessing a ${terminal.Definition.Name}") + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_use") + sendResponse(msg) + } + case _ => () + } + } + + private def handleUseSpawnTube(obj: SpawnTube, equipment: Option[Equipment]): Unit = { + equipment match { + case Some(item) => + sendUseGeneralEntityMessage(obj, item) + case None if player.Faction == obj.Faction => + //deconstruction + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_use") + sessionLogic.actionsToCancel() + sessionLogic.terminals.CancelAllProximityUnits() + sessionLogic.zoning.spawn.startDeconstructing(obj) + case _ => () + } + } + + 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))) => + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel") + 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) => + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel") + 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 => () + } + } + + private def handleUseCaptureFlag(obj: CaptureFlag): Unit = { + // LLU can normally only be picked up the faction that owns it + ops.specialItemSlotGuid match { + case None if obj.Faction == player.Faction => + ops.specialItemSlotGuid = Some(obj.GUID) + player.Carrying = SpecialCarry.CaptureFlag + continent.LocalEvents ! CaptureFlagManager.PickupFlag(obj, player) + case None => + log.warn(s"${player.Faction} player ${player.toString} tried to pick up a ${obj.Faction} LLU - ${obj.GUID}") + case Some(guid) if guid != obj.GUID => + // Ignore duplicate pickup requests + log.warn( + s"${player.Faction} player ${player.toString} tried to pick up a ${obj.Faction} LLU, but their special slot already contains $guid" + ) + case _ => () + } + } + + private def handleUseWarpGate(equipment: Option[Equipment]): Unit = { + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_use") + (continent.GUID(player.VehicleSeated), equipment) match { + case (Some(vehicle: Vehicle), Some(item)) + if GlobalDefinitions.isBattleFrameVehicle(vehicle.Definition) && + GlobalDefinitions.isBattleFrameNTUSiphon(item.Definition) => + vehicle.Actor ! CommonMessages.Use(player, equipment) + case _ => () + } + } + + private def handleUseGeneralEntity(obj: PlanetSideServerObject, equipment: Option[Equipment]): Unit = { + equipment.foreach { item => + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_use") + obj.Actor ! CommonMessages.Use(player, Some(item)) + } + } + + private def sendUseGeneralEntityMessage(obj: PlanetSideServerObject, equipment: Equipment): Unit = { + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_use") + obj.Actor ! CommonMessages.Use(player, Some(equipment)) + } + + private def handleUseDefaultEntity(obj: PlanetSideGameObject, equipment: Option[Equipment]): Unit = { + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_use") + equipment match { + case Some(item) + if GlobalDefinitions.isBattleFrameArmorSiphon(item.Definition) || + GlobalDefinitions.isBattleFrameNTUSiphon(item.Definition) => () + case _ => + log.warn(s"UseItem: ${player.Name} does not know how to handle $obj") + } + } + + /** + * 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 maxCapacitorTick(jumpThrust: Boolean): Unit = { + if (player.ExoSuit == ExoSuitType.MAX) { + val activate = (jumpThrust || player.isOverdrived || player.isShielded) && player.Capacitor > 0 + player.CapacitorState match { + case CapacitorStateType.Idle => maxCapacitorTickIdle(activate) + case CapacitorStateType.Discharging => maxCapacitorTickDischarging(activate) + case CapacitorStateType.ChargeDelay => maxCapacitorTickChargeDelay(activate) + case CapacitorStateType.Charging => maxCapacitorTickCharging(activate) + } + } else if (player.CapacitorState != CapacitorStateType.Idle) { + player.CapacitorState = CapacitorStateType.Idle + } + } + + private def maxCapacitorTickIdle(activate: Boolean): Unit = { + if (activate) { + player.CapacitorState = CapacitorStateType.Discharging + //maxCapacitorTickDischarging(activate) + } else if (player.Capacitor < player.ExoSuitDef.MaxCapacitor) { + player.CapacitorState = CapacitorStateType.ChargeDelay + maxCapacitorTickChargeDelay(activate) + } + } + + private def maxCapacitorTickDischarging(activate: Boolean): Unit = { + if (activate) { + val timeDiff = (System.currentTimeMillis() - player.CapacitorLastUsedMillis).toFloat / 1000 + val drainAmount = player.ExoSuitDef.CapacitorDrainPerSecond.toFloat * timeDiff + player.Capacitor -= drainAmount + sendResponse(PlanetsideAttributeMessage(player.GUID, 7, player.Capacitor.toInt)) + } else if (player.Capacitor < player.ExoSuitDef.MaxCapacitor) { + if (player.Faction != PlanetSideEmpire.VS) { + ops.toggleMaxSpecialState(enable = false) + } + player.CapacitorState = CapacitorStateType.ChargeDelay + maxCapacitorTickChargeDelay(activate) + } else { + player.CapacitorState = CapacitorStateType.Idle + } + } + + private def maxCapacitorTickChargeDelay(activate: Boolean): Unit = { + if (activate) { + player.CapacitorState = CapacitorStateType.Discharging + //maxCapacitorTickDischarging(activate) + } else if (player.Capacitor == player.ExoSuitDef.MaxCapacitor) { + player.CapacitorState = CapacitorStateType.Idle + } else if (System.currentTimeMillis() - player.CapacitorLastUsedMillis > player.ExoSuitDef.CapacitorRechargeDelayMillis) { + player.CapacitorState = CapacitorStateType.Charging + //maxCapacitorTickCharging(activate) + } + } + + private def maxCapacitorTickCharging(activate: Boolean): Unit = { + if (activate) { + player.CapacitorState = CapacitorStateType.Discharging + //maxCapacitorTickDischarging(activate) + } else if (player.Capacitor < player.ExoSuitDef.MaxCapacitor) { + val timeDiff = (System.currentTimeMillis() - player.CapacitorLastChargedMillis).toFloat / 1000 + val chargeAmount = player.ExoSuitDef.CapacitorRechargePerSecond * timeDiff + player.Capacitor += chargeAmount + sendResponse(PlanetsideAttributeMessage(player.GUID, 7, player.Capacitor.toInt)) + } else { + player.CapacitorState = CapacitorStateType.Idle + } + } + + private def updateCollisionHistoryForTarget( + target: PlanetSideServerObject with Vitality with FactionAffinity, + curr: Long + ): Boolean = { + ops.collisionHistory.get(target.Actor) match { + case Some(lastCollision) if curr - lastCollision <= 1000L => + false + case _ => + ops.collisionHistory.put(target.Actor, curr) + true + } + } + + private def collisionBetweenVehicleAndFragileDeployable( + vehicle: Vehicle, + vehiclePosition: Vector3, + smallDeployable: Deployable, + smallDeployablePosition: Vector3, + velocity: Vector3, + fallHeight: Float, + collisionTime: Long + ): Unit = { + if (updateCollisionHistoryForTarget(smallDeployable, collisionTime)) { + val smallDeployableSource = SourceEntry(smallDeployable) + //vehicle takes damage from the collision (ignore bail protection in this case) + performCollisionWithSomethingDamage(vehicle, SourceEntry(vehicle), vehiclePosition, smallDeployableSource, fallHeight, velocity) + //deployable gets absolutely destroyed + ops.collisionHistory.put(vehicle.Actor, collisionTime) + sessionLogic.handleDealingDamage( + smallDeployable, + DamageInteraction(smallDeployableSource, SuicideReason(), smallDeployablePosition) + ) + } + } + + private def performCollisionWithSomethingDamage( + target: PlanetSideServerObject with Vitality with FactionAffinity, + targetSource: SourceEntry, + targetPosition: Vector3, + victimSource: SourceEntry, + fallHeight: Float, + velocity: Vector3 + ): Unit = { + sessionLogic.handleDealingDamage( + target, + DamageInteraction( + targetSource, + CollisionWithReason(CollisionReason(velocity, fallHeight, target.DamageModel), victimSource), + targetPosition + ) + ) + } + + private def commonFacilityShieldCharging(obj: PlanetSideServerObject with BlockMapEntity): Unit = { + obj.Actor ! CommonMessages.ChargeShields( + 15, + Some(continent.blockMap.sector(obj).buildingList.maxBy(_.Definition.SOIRadius)) + ) + } +} diff --git a/src/main/scala/net/psforever/actors/session/csr/LocalHandlerLogic.scala b/src/main/scala/net/psforever/actors/session/csr/LocalHandlerLogic.scala new file mode 100644 index 000000000..37d7c2f62 --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/csr/LocalHandlerLogic.scala @@ -0,0 +1,268 @@ +// Copyright (c) 2024 PSForever +package net.psforever.actors.session.csr + +import akka.actor.ActorContext +import net.psforever.actors.session.support.{LocalHandlerFunctions, SessionData, SessionLocalHandlers} +import net.psforever.objects.ce.Deployable +import net.psforever.objects.serverobject.doors.Door +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, HackState1, 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 + + /* messages */ + + def handleTurretDeployableIsDismissed(obj: TurretDeployable): Unit = { + ops.handleTurretDeployableIsDismissed(obj) + } + + def handleDeployableIsDismissed(obj: Deployable): Unit = { + ops.handleDeployableIsDismissed(obj) + } + + /* 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 => + val pos = player.Position.xy + val range = ops.doorLoadRange() + val foundDoor = continent + .blockMap + .sector(pos, range) + .amenityList + .collect { case door: Door => door } + .find(_.GUID == doorGuid) + val doorExistsInRange: Boolean = foundDoor.nonEmpty + if (doorExistsInRange) { + 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 + ops.deactivateTelpadDeployableMessages(dguid) + //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 + ops.deactivateTelpadDeployableMessages(dguid) + //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(HackState1.Unk0, targetGuid, guid, progress=0, unk1.toFloat, 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/csr/MountHandlerLogic.scala b/src/main/scala/net/psforever/actors/session/csr/MountHandlerLogic.scala new file mode 100644 index 000000000..91e470beb --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/csr/MountHandlerLogic.scala @@ -0,0 +1,520 @@ +// Copyright (c) 2024 PSForever +package net.psforever.actors.session.csr + +import akka.actor.{ActorContext, typed} +import net.psforever.actors.session.AvatarActor +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.definition.{BasicDefinition, ObjectDefinition} +import net.psforever.objects.serverobject.affinity.FactionAffinity +import net.psforever.objects.serverobject.environment.interaction.ResetAllEnvironmentInteractions +import net.psforever.objects.serverobject.hackable.GenericHackables +import net.psforever.objects.serverobject.mount.Mountable +import net.psforever.objects.serverobject.terminals.implant.ImplantTerminalMech +import net.psforever.objects.serverobject.turret.{FacilityTurret, WeaponTurret} +import net.psforever.objects.vehicles.{AccessPermissionGroup, CargoBehavior} +import net.psforever.objects.vital.InGameHistory +import net.psforever.packet.game.{ChatMsg, DelayedPathMountMsg, DismountVehicleCargoMsg, DismountVehicleMsg, GenericObjectActionMessage, MountVehicleCargoMsg, MountVehicleMsg, ObjectAttachMessage, ObjectDetachMessage, PlanetsideAttributeMessage, 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, ChatMessageType, PlanetSideGUID, Vector3} + +import scala.concurrent.duration._ + +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 + + private val avatarActor: typed.ActorRef[AvatarActor.Command] = ops.avatarActor + + /* packets */ + + def handleMountVehicle(pkt: MountVehicleMsg): Unit = { + val MountVehicleMsg(_, mountable_guid, entry_point) = pkt + sessionLogic.validObject(mountable_guid, decorator = "MountVehicle").collect { + case obj: Mountable => + obj.Actor ! Mountable.TryMount(player, entry_point) + case _ => + log.error(s"MountVehicleMsg: object ${mountable_guid.guid} not a mountable thing, ${player.Name}") + } + } + + 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 = { + val MountVehicleCargoMsg(_, cargo_guid, carrier_guid, _) = pkt + (continent.GUID(cargo_guid), continent.GUID(carrier_guid)) match { + case (Some(cargo: Vehicle), Some(carrier: Vehicle)) => + carrier.CargoHolds.find({ case (_, hold) => !hold.isOccupied }) match { + case Some((mountPoint, _)) => + cargo.Actor ! CargoBehavior.StartCargoMounting(carrier_guid, mountPoint) + case _ => + log.warn( + s"MountVehicleCargoMsg: ${player.Name} trying to load cargo into a ${carrier.Definition.Name} which oes not have a cargo hold" + ) + } + case (None, _) | (Some(_), None) => + log.warn( + s"MountVehicleCargoMsg: ${player.Name} lost a vehicle while working with cargo - either $carrier_guid or $cargo_guid" + ) + case _ => () + } + } + + 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.CanMount(obj: ImplantTerminalMech, seatNumber, _) => + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_use") + log.info(s"${player.Name} mounts an implant terminal") + sessionLogic.terminals.CancelAllProximityUnits() + MountingAction(tplayer, obj, seatNumber) + sessionLogic.keepAliveFunc = sessionLogic.keepAlivePersistence + + case Mountable.CanMount(obj: Vehicle, seatNumber, _) + if obj.Definition == GlobalDefinitions.orbital_shuttle => + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_mount") + log.info(s"${player.Name} mounts the orbital shuttle") + sessionLogic.terminals.CancelAllProximityUnits() + MountingAction(tplayer, obj, seatNumber) + sessionLogic.keepAliveFunc = sessionLogic.keepAlivePersistence + + case Mountable.CanMount(obj: Vehicle, seatNumber, _) + if obj.Definition == GlobalDefinitions.ant => + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_mount") + log.info(s"${player.Name} mounts the driver seat of the ${obj.Definition.Name}") + val obj_guid: PlanetSideGUID = obj.GUID + sessionLogic.terminals.CancelAllProximityUnits() + sendResponse(PlanetsideAttributeMessage(obj_guid, attribute_type=0, obj.Health)) + sendResponse(PlanetsideAttributeMessage(obj_guid, obj.Definition.shieldUiAttribute, obj.Shields)) + sendResponse(PlanetsideAttributeMessage(obj_guid, attribute_type=45, obj.NtuCapacitorScaled)) + sendResponse(GenericObjectActionMessage(obj_guid, code=11)) + sessionLogic.general.accessContainer(obj) + tplayer.Actor ! ResetAllEnvironmentInteractions + MountingAction(tplayer, obj, seatNumber) + + case Mountable.CanMount(obj: Vehicle, seatNumber, _) + if obj.Definition == GlobalDefinitions.quadstealth => + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_mount") + log.info(s"${player.Name} mounts the driver seat of the ${obj.Definition.Name}") + val obj_guid: PlanetSideGUID = obj.GUID + sessionLogic.terminals.CancelAllProximityUnits() + sendResponse(PlanetsideAttributeMessage(obj_guid, attribute_type=0, obj.Health)) + sendResponse(PlanetsideAttributeMessage(obj_guid, obj.Definition.shieldUiAttribute, obj.Shields)) + //exclusive to the wraith, cloak state matches the cloak state of the driver + //phantasm doesn't uncloak if the driver is uncloaked and no other vehicle cloaks + obj.Cloaked = tplayer.Cloaked + sendResponse(GenericObjectActionMessage(obj_guid, code=11)) + sessionLogic.general.accessContainer(obj) + tplayer.Actor ! ResetAllEnvironmentInteractions + MountingAction(tplayer, obj, seatNumber) + + case Mountable.CanMount(obj: Vehicle, seatNumber, _) + if seatNumber == 0 && obj.Definition.MaxCapacitor > 0 => + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_mount") + log.info(s"${player.Name} mounts the driver seat of the ${obj.Definition.Name}") + val obj_guid: PlanetSideGUID = obj.GUID + sessionLogic.terminals.CancelAllProximityUnits() + sendResponse(PlanetsideAttributeMessage(obj_guid, attribute_type=0, obj.Health)) + sendResponse(PlanetsideAttributeMessage(obj_guid, obj.Definition.shieldUiAttribute, obj.Shields)) + sendResponse(PlanetsideAttributeMessage(obj_guid, attribute_type=113, obj.Capacitor)) + sendResponse(GenericObjectActionMessage(obj_guid, code=11)) + sessionLogic.general.accessContainer(obj) + ops.updateWeaponAtSeatPosition(obj, seatNumber) + tplayer.Actor ! ResetAllEnvironmentInteractions + MountingAction(tplayer, obj, seatNumber) + + case Mountable.CanMount(obj: Vehicle, seatNumber, _) + if seatNumber == 0 => + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_mount") + log.info(s"${player.Name} mounts the driver seat of the ${obj.Definition.Name}") + val obj_guid: PlanetSideGUID = obj.GUID + sessionLogic.terminals.CancelAllProximityUnits() + sendResponse(PlanetsideAttributeMessage(obj_guid, attribute_type=0, obj.Health)) + sendResponse(PlanetsideAttributeMessage(obj_guid, obj.Definition.shieldUiAttribute, obj.Shields)) + sendResponse(GenericObjectActionMessage(obj_guid, code=11)) + sessionLogic.general.accessContainer(obj) + ops.updateWeaponAtSeatPosition(obj, seatNumber) + tplayer.Actor ! ResetAllEnvironmentInteractions + MountingAction(tplayer, obj, seatNumber) + + case Mountable.CanMount(obj: Vehicle, seatNumber, _) + if obj.Definition.MaxCapacitor > 0 => + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_mount") + log.info(s"${player.Name} mounts ${ + obj.SeatPermissionGroup(seatNumber) match { + case Some(seatType) => s"a $seatType seat (#$seatNumber)" + case None => "a seat" + } + } of the ${obj.Definition.Name}") + val obj_guid: PlanetSideGUID = obj.GUID + sessionLogic.terminals.CancelAllProximityUnits() + sendResponse(PlanetsideAttributeMessage(obj_guid, attribute_type=0, obj.Health)) + sendResponse(PlanetsideAttributeMessage(obj_guid, obj.Definition.shieldUiAttribute, obj.Shields)) + sendResponse(PlanetsideAttributeMessage(obj_guid, attribute_type=113, obj.Capacitor)) + sessionLogic.general.accessContainer(obj) + ops.updateWeaponAtSeatPosition(obj, seatNumber) + sessionLogic.keepAliveFunc = sessionLogic.keepAlivePersistence + tplayer.Actor ! ResetAllEnvironmentInteractions + MountingAction(tplayer, obj, seatNumber) + + case Mountable.CanMount(obj: Vehicle, seatNumber, _) => + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_mount") + log.info(s"${player.Name} mounts the ${ + obj.SeatPermissionGroup(seatNumber) match { + case Some(seatType) => s"a $seatType seat (#$seatNumber)" + case None => "a seat" + } + } of the ${obj.Definition.Name}") + val obj_guid: PlanetSideGUID = obj.GUID + sessionLogic.terminals.CancelAllProximityUnits() + sendResponse(PlanetsideAttributeMessage(obj_guid, attribute_type=0, obj.Health)) + sendResponse(PlanetsideAttributeMessage(obj_guid, obj.Definition.shieldUiAttribute, obj.Shields)) + sessionLogic.general.accessContainer(obj) + ops.updateWeaponAtSeatPosition(obj, seatNumber) + sessionLogic.keepAliveFunc = sessionLogic.keepAlivePersistence + tplayer.Actor ! ResetAllEnvironmentInteractions + MountingAction(tplayer, obj, seatNumber) + + case Mountable.CanMount(obj: FacilityTurret, seatNumber, _) + if obj.Definition == GlobalDefinitions.vanu_sentry_turret => + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_mount") + log.info(s"${player.Name} mounts the ${obj.Definition.Name}") + obj.Zone.LocalEvents ! LocalServiceMessage(obj.Zone.id, LocalAction.SetEmpire(obj.GUID, player.Faction)) + sendResponse(PlanetsideAttributeMessage(obj.GUID, attribute_type=0, obj.Health)) + ops.updateWeaponAtSeatPosition(obj, seatNumber) + MountingAction(tplayer, obj, seatNumber) + + case Mountable.CanMount(obj: FacilityTurret, seatNumber, _) + if !obj.isUpgrading || System.currentTimeMillis() - GenericHackables.getTurretUpgradeTime >= 1500L => + obj.setMiddleOfUpgrade(false) + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_mount") + log.info(s"${player.Name} mounts the ${obj.Definition.Name}") + sendResponse(PlanetsideAttributeMessage(obj.GUID, attribute_type=0, obj.Health)) + ops.updateWeaponAtSeatPosition(obj, seatNumber) + MountingAction(tplayer, obj, seatNumber) + + case Mountable.CanMount(obj: FacilityTurret, _, _) => + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_mount") + log.warn( + s"MountVehicleMsg: ${tplayer.Name} wants to mount turret ${obj.GUID.guid}, but needs to wait until it finishes updating" + ) + + case Mountable.CanMount(obj: PlanetSideGameObject with FactionAffinity with WeaponTurret with InGameHistory, seatNumber, _) => + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_mount") + log.info(s"${player.Name} mounts the ${obj.Definition.asInstanceOf[BasicDefinition].Name}") + sendResponse(PlanetsideAttributeMessage(obj.GUID, attribute_type=0, obj.Health)) + ops.updateWeaponAtSeatPosition(obj, seatNumber) + MountingAction(tplayer, obj, seatNumber) + + case Mountable.CanMount(obj: Mountable, _, _) => + log.warn(s"MountVehicleMsg: $obj is some kind of mountable object but nothing will happen for ${player.Name}") + + case Mountable.CanDismount(obj: ImplantTerminalMech, seatNum, _) => + log.info(s"${tplayer.Name} dismounts the implant terminal") + DismountAction(tplayer, obj, seatNum) + + case Mountable.CanDismount(obj: Vehicle, _, mountPoint) + if obj.Definition == GlobalDefinitions.orbital_shuttle && obj.MountedIn.nonEmpty => + //dismount to hart lobby + val pguid = player.GUID + log.info(s"${tplayer.Name} dismounts the orbital shuttle into the lobby") + 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)) + ) + 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 + log.info(s"${player.Name} is prepped for dropping") + 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 + ) + sessionLogic.keepAliveFunc = sessionLogic.zoning.NormalKeepAlive + + case Mountable.CanDismount(obj: Vehicle, seatNum, _) + if obj.Definition == GlobalDefinitions.droppod => + log.info(s"${tplayer.Name} has landed on ${continent.id}") + sessionLogic.general.unaccessContainer(obj) + DismountAction(tplayer, obj, seatNum) + obj.Actor ! Vehicle.Deconstruct() + + case Mountable.CanDismount(obj: Vehicle, seatNum, _) + if tplayer.GUID == player.GUID => + //disembarking self + log.info(s"${player.Name} dismounts the ${obj.Definition.Name}'s ${ + obj.SeatPermissionGroup(seatNum) match { + case Some(AccessPermissionGroup.Driver) => "driver seat" + case Some(seatType) => s"$seatType seat (#$seatNum)" + case None => "seat" + } + }") + 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, _) => + log.info(s"${tplayer.Name} dismounts a ${obj.Definition.asInstanceOf[ObjectDefinition].Name}") + DismountAction(tplayer, obj, seatNum) + + case Mountable.CanDismount(obj: Mountable, _, _) => + log.warn(s"DismountVehicleMsg: $obj is some dismountable object but nothing will happen for ${player.Name}") + + case Mountable.CanNotMount(obj: Vehicle, seatNumber) => + log.warn(s"MountVehicleMsg: ${tplayer.Name} attempted to mount $obj's seat $seatNumber, but was not allowed") + obj.GetSeatFromMountPoint(seatNumber).collect { + case seatNum if obj.SeatPermissionGroup(seatNum).contains(AccessPermissionGroup.Driver) => + sendResponse( + ChatMsg(ChatMessageType.CMT_OPEN, wideContents=false, recipient="", "You are not the driver of this vehicle.", note=None) + ) + } + + case Mountable.CanNotMount(obj: Mountable, seatNumber) => + log.warn(s"MountVehicleMsg: ${tplayer.Name} attempted to mount $obj's seat $seatNumber, but was not allowed") + + case Mountable.CanNotDismount(obj, seatNum) => + log.warn(s"DismountVehicleMsg: ${tplayer.Name} attempted to dismount $obj's mount $seatNum, but was not allowed") + } + } + + /* 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 mounts a valid object. + * @param tplayer the player + * @param obj the mountable object + * @param seatNum the mount into which the player is mounting + */ + 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() + avatarActor ! AvatarActor.DeactivateActiveImplants + avatarActor ! AvatarActor.SuspendStaminaRegeneration(3.seconds) + sendResponse(ObjectAttachMessage(objGuid, playerGuid, seatNum)) + continent.VehicleEvents ! VehicleServiceMessage( + continent.id, + VehicleAction.MountVehicle(playerGuid, objGuid, seatNum) + ) + } + + /** + * 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 + ) + ) + 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/csr/SpectateAsCustomerServiceRepresentativeMode.scala b/src/main/scala/net/psforever/actors/session/csr/SpectateAsCustomerServiceRepresentativeMode.scala new file mode 100644 index 000000000..665e60cf9 --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/csr/SpectateAsCustomerServiceRepresentativeMode.scala @@ -0,0 +1,120 @@ +// Copyright (c) 2024 PSForever +package net.psforever.actors.session.csr + +import net.psforever.actors.session.support.{AvatarHandlerFunctions, ChatFunctions, GalaxyHandlerFunctions, GeneralFunctions, LocalHandlerFunctions, MountHandlerFunctions, SquadHandlerFunctions, TerminalHandlerFunctions, VehicleFunctions, VehicleHandlerFunctions, WeaponAndProjectileFunctions} +import net.psforever.actors.zone.ZoneActor +import net.psforever.objects.serverobject.ServerObject +import net.psforever.objects.{Session, Vehicle} +import net.psforever.packet.PlanetSidePacket +import net.psforever.packet.game.ObjectDeleteMessage +import net.psforever.packet.game.objectcreate.ObjectCreateMessageParent +import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage} +import net.psforever.services.chat.SpectatorChannel +import net.psforever.services.teamwork.{SquadAction, SquadServiceMessage} +import net.psforever.types.{CapacitorStateType, ChatMessageType, SquadRequestType} +// +import net.psforever.actors.session.support.{ModeLogic, PlayerMode, SessionData} +import net.psforever.objects.serverobject.terminals.{ProximityUnit, Terminal} +import net.psforever.packet.game.{ChatMsg, UnuseItemMessage} + +class SpectatorCSRModeLogic(data: SessionData) extends ModeLogic { + val avatarResponse: AvatarHandlerFunctions = AvatarHandlerLogic(data.avatarResponse) + val chat: ChatFunctions = ChatLogic(data.chat) + 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 + // + continent.actor ! ZoneActor.RemoveFromBlockMap(player) + 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)) } + 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.Seat(seatNum).foreach(_.unmount(player)) + player.VehicleSeated = None + Some(ObjectCreateMessageParent(obj.GUID, seatNum)) + case (Some(obj), Some(seatNum)) => + obj.Seat(seatNum).foreach(_.unmount(player)) + player.VehicleSeated = None + Some(ObjectCreateMessageParent(obj.GUID, seatNum)) + case _ => () + } + 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) + ) + if (player.silenced) { + data.chat.commandIncomingSilence(session, ChatMsg(ChatMessageType.CMT_SILENCE, "player 0")) + } + // + player.spectator = true + data.chat.JoinChannel(SpectatorChannel) + sendResponse(ChatMsg(ChatMessageType.CMT_TOGGLESPECTATORMODE, "on")) + sendResponse(ChatMsg(ChatMessageType.UNK_225, "CSR SPECTATOR MODE")) + data.session = session.copy(player = player) + } + + override def switchFrom(session: Session): Unit = { + val player = data.player + val sendResponse: PlanetSidePacket => Unit = data.sendResponse + // + data.continent.actor ! ZoneActor.AddToBlockMap(player, player.Position) + data.general.stop() + data.chat.LeaveChannel(SpectatorChannel) + player.spectator = false + sendResponse(ChatMsg(ChatMessageType.CMT_TOGGLESPECTATORMODE, "off")) + sendResponse(ChatMsg(ChatMessageType.UNK_227, "@SpectatorDisabled")) + } +} + +case object SpectateAsCustomerServiceRepresentativeMode extends PlayerMode { + def setup(data: SessionData): ModeLogic = { + new SpectatorCSRModeLogic(data) + } +} diff --git a/src/main/scala/net/psforever/actors/session/csr/SquadHandlerLogic.scala b/src/main/scala/net/psforever/actors/session/csr/SquadHandlerLogic.scala new file mode 100644 index 000000000..fae51f9f7 --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/csr/SquadHandlerLogic.scala @@ -0,0 +1,358 @@ +// Copyright (c) 2024 PSForever +package net.psforever.actors.session.csr + +import akka.actor.{ActorContext, ActorRef, typed} +import net.psforever.actors.session.support.SessionSquadHandlers.SquadUIElement +import net.psforever.actors.session.AvatarActor +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, ChatMsg, MemberEvent, PlanetsideAttributeMessage, ReplicationStreamMessage, SquadAction, SquadDefinitionActionMessage, SquadDetailDefinitionUpdateMessage, SquadListing, SquadMemberEvent, SquadMembershipRequest, SquadMembershipResponse, SquadState, SquadStateInfo, SquadWaypointEvent, SquadWaypointRequest, WaypointEvent, WaypointEventAction} +import net.psforever.services.chat.SquadChannel +import net.psforever.services.teamwork.{SquadResponse, SquadServiceMessage, SquadAction => SquadServiceAction} +import net.psforever.types.{ChatMessageType, PlanetSideGUID, SquadListDecoration, SquadResponseType, WaypointSubtype} + +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 squadService: ActorRef = ops.squadService + + private var waypointCooldown: Long = 0L + + /* packet */ + + def handleSquadDefinitionAction(pkt: SquadDefinitionActionMessage): Unit = { + val SquadDefinitionActionMessage(u1, u2, action) = pkt + squadService ! SquadServiceMessage(player, continent, SquadServiceAction.Definition(u1, u2, action)) + } + + def handleSquadMemberRequest(pkt: SquadMembershipRequest): Unit = { + val SquadMembershipRequest(request_type, char_id, unk3, player_name, unk5) = pkt + squadService ! SquadServiceMessage( + player, + continent, + SquadServiceAction.Membership(request_type, char_id, unk3, player_name, unk5) + ) + } + + def handleSquadWaypointRequest(pkt: SquadWaypointRequest): Unit = { + val SquadWaypointRequest(request, _, wtype, unk, info) = pkt + val time = System.currentTimeMillis() + val subtype = wtype.subtype + if(subtype == WaypointSubtype.Squad) { + squadService ! SquadServiceMessage(player, continent, SquadServiceAction.Waypoint(request, wtype, unk, info)) + } else if (subtype == WaypointSubtype.Laze && time - waypointCooldown > 1000) { + //guarding against duplicating laze waypoints + waypointCooldown = time + squadService ! SquadServiceMessage(player, continent, SquadServiceAction.Waypoint(request, wtype, unk, info)) + } + } + + /* response handlers */ + + def handle(response: SquadResponse.Response, excluded: Iterable[Long]): Unit = { + if (!excluded.exists(_ == avatar.id)) { + response match { + case SquadResponse.ListSquadFavorite(line, task) => + sendResponse(SquadDefinitionActionMessage(PlanetSideGUID(0), line, SquadAction.ListSquadFavorite(task))) + + 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.IdentifyAsSquadLeader(squad_guid) => + sendResponse(SquadDefinitionActionMessage(squad_guid, 0, SquadAction.IdentifyAsSquadLeader())) + + case SquadResponse.SetListSquad(squad_guid) => + sendResponse(SquadDefinitionActionMessage(squad_guid, 0, SquadAction.SetListSquad())) + + 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.WantsSquadPosition(_, name) => + sendResponse( + ChatMsg( + ChatMessageType.CMT_SQUAD, + wideContents=true, + name, + s"\\#6 would like to join your squad. (respond with \\#3/accept\\#6 or \\#3/reject\\#6)", + None + ) + ) + + case SquadResponse.Join(squad, positionsToUpdate, _, ref) => + val avatarId = avatar.id + val membershipPositions = (positionsToUpdate map squad.Membership.zipWithIndex) + .filter { case (mem, index) => + mem.CharId > 0 && positionsToUpdate.contains(index) + } + membershipPositions.find { case (mem, _) => mem.CharId == avatarId } match { + case Some((ourMember, ourIndex)) => + //we are joining the squad + //load each member's entry (our own too) + ops.squad_supplement_id = squad.GUID.guid + 1 + membershipPositions.foreach { + case (member, index) => + sendResponse( + SquadMemberEvent.Add( + ops.squad_supplement_id, + member.CharId, + index, + member.Name, + member.ZoneId, + outfit_id = 0 + ) + ) + ops.squadUI(member.CharId) = + SquadUIElement(member.Name, outfit=0L, index, member.ZoneId, member.Health, member.Armor, member.Position) + } + //repeat our entry + sendResponse( + SquadMemberEvent.Add( + ops.squad_supplement_id, + ourMember.CharId, + ourIndex, + ourMember.Name, + ourMember.ZoneId, + outfit_id = 0 + ) + ) + //turn lfs off + if (avatar.lookingForSquad) { + avatarActor ! AvatarActor.SetLookingForSquad(false) + } + val playerGuid = player.GUID + val factionChannel = s"${player.Faction}" + //squad colors + ops.GiveSquadColorsToMembers() + ops.GiveSquadColorsForOthers(playerGuid, factionChannel, ops.squad_supplement_id) + //associate with member position in squad + sendResponse(PlanetsideAttributeMessage(playerGuid, 32, ourIndex)) + //a finalization? what does this do? + sendResponse(SquadDefinitionActionMessage(squad.GUID, 0, SquadAction.Unknown(18))) + squadService ! SquadServiceMessage(player, continent, SquadServiceAction.ReloadDecoration()) + ops.updateSquadRef = ref + ops.updateSquad = ops.PeriodicUpdatesWhenEnrolledInSquad + sessionLogic.chat.JoinChannel(SquadChannel(squad.GUID)) + case _ => + //other player is joining our squad + //load each member's entry + ops.GiveSquadColorsToMembers( + membershipPositions.map { + case (member, index) => + val charId = member.CharId + sendResponse( + SquadMemberEvent.Add(ops.squad_supplement_id, charId, index, member.Name, member.ZoneId, outfit_id = 0) + ) + ops.squadUI(charId) = + SquadUIElement(member.Name, outfit=0L, index, member.ZoneId, member.Health, member.Armor, member.Position) + charId + } + ) + } + //send an initial dummy update for map icon(s) + sendResponse( + SquadState( + PlanetSideGUID(ops.squad_supplement_id), + membershipPositions.map { case (member, _) => + SquadStateInfo(member.CharId, member.Health, member.Armor, member.Position) + } + ) + ) + + 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 + sessionLogic.chat.LeaveChannel(SquadChannel(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.AssignMember(squad, from_index, to_index) => + //we've already swapped position internally; now we swap the cards + ops.SwapSquadUIElements(squad, from_index, to_index) + + case SquadResponse.PromoteMember(squad, promotedPlayer, from_index) => + if (promotedPlayer != player.CharId) { + //demoted from leader; no longer lfsm + if (player.avatar.lookingForSquad) { + avatarActor ! AvatarActor.SetLookingForSquad(false) + } + } + sendResponse(SquadMemberEvent(MemberEvent.Promote, squad.GUID.guid, promotedPlayer, position = 0)) + //the players have already been swapped in the backend object + ops.PromoteSquadUIElements(squad, from_index) + + 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.SquadSearchResults(_/*results*/) => + //TODO positive squad search results message? + // if(results.nonEmpty) { + // results.foreach { guid => + // sendResponse(SquadDefinitionActionMessage( + // guid, + // 0, + // SquadAction.SquadListDecorator(SquadListDecoration.SearchResult)) + // ) + // } + // } else { + // sendResponse(SquadDefinitionActionMessage(player.GUID, 0, SquadAction.NoSquadSearchResults())) + // } + // sendResponse(SquadDefinitionActionMessage(player.GUID, 0, SquadAction.CancelSquadSearch())) + + case SquadResponse.InitWaypoints(char_id, waypoints) => + waypoints.foreach { + case (waypoint_type, info, unk) => + sendResponse( + SquadWaypointEvent.Add( + ops.squad_supplement_id, + char_id, + waypoint_type, + WaypointEvent(info.zone_number, info.pos, unk) + ) + ) + } + + case SquadResponse.WaypointEvent(WaypointEventAction.Add, char_id, waypoint_type, _, Some(info), unk) => + sendResponse( + SquadWaypointEvent.Add( + ops.squad_supplement_id, + char_id, + waypoint_type, + WaypointEvent(info.zone_number, info.pos, unk) + ) + ) + + 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/csr/TerminalHandlerLogic.scala b/src/main/scala/net/psforever/actors/session/csr/TerminalHandlerLogic.scala new file mode 100644 index 000000000..ba6843ceb --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/csr/TerminalHandlerLogic.scala @@ -0,0 +1,184 @@ +// Copyright (c) 2024 PSForever +package net.psforever.actors.session.csr + +import akka.actor.{ActorContext, typed} +import net.psforever.actors.session.AvatarActor +import net.psforever.actors.session.support.{SessionData, SessionTerminalHandlers, TerminalHandlerFunctions} +import net.psforever.login.WorldSession.{BuyNewEquipmentPutInInventory, SellEquipmentFromInventory} +import net.psforever.objects.{GlobalDefinitions, Player, Vehicle} +import net.psforever.objects.guid.TaskWorkflow +import net.psforever.objects.serverobject.pad.VehicleSpawnPad +import net.psforever.objects.serverobject.terminals.{ProximityUnit, Terminal} +import net.psforever.objects.sourcing.AmenitySource +import net.psforever.objects.vital.TerminalUsedActivity +import net.psforever.packet.game.{FavoritesAction, FavoritesRequest, ItemTransactionMessage, ItemTransactionResultMessage, ProximityTerminalUseMessage, UnuseItemMessage} +import net.psforever.types.{TransactionType, Vector3} + +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 + + private val avatarActor: typed.ActorRef[AvatarActor.Command] = ops.avatarActor + + def handleItemTransaction(pkt: ItemTransactionMessage): Unit = { + val ItemTransactionMessage(terminalGuid, transactionType, _, itemName, _, _) = pkt + continent.GUID(terminalGuid) match { + case Some(term: Terminal) if ops.lastTerminalOrderFulfillment => + val msg: String = if (itemName.nonEmpty) s" of $itemName" else "" + log.info(s"${player.Name} is submitting an order - a $transactionType from a ${term.Definition.Name}$msg") + ops.lastTerminalOrderFulfillment = false + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_use") + term.Actor ! Terminal.Request(player, pkt) + case Some(_: Terminal) => + log.warn(s"Please Wait until your previous order has been fulfilled, ${player.Name}") + case Some(obj) => + log.error(s"ItemTransaction: ${obj.Definition.Name} is not a terminal, ${player.Name}") + case _ => + log.error(s"ItemTransaction: entity with guid=${terminalGuid.guid} does not exist, ${player.Name}") + } + } + + def handleProximityTerminalUse(pkt: ProximityTerminalUseMessage): Unit = { + val ProximityTerminalUseMessage(_, objectGuid, _) = pkt + continent.GUID(objectGuid) match { + case Some(obj: Terminal with ProximityUnit) => + ops.HandleProximityTerminalUse(obj) + case Some(obj) => + log.warn(s"ProximityTerminalUse: ${obj.Definition.Name} guid=${objectGuid.guid} is not ready to implement proximity effects") + case None => + log.error(s"ProximityTerminalUse: ${player.Name} can not find an object with guid ${objectGuid.guid}") + } + } + + def handleFavoritesRequest(pkt: FavoritesRequest): Unit = { + val FavoritesRequest(_, loadoutType, action, line, label) = pkt + sessionLogic.zoning.CancelZoningProcessWithDescriptiveReason("cancel_use") + action match { + case FavoritesAction.Save => + avatarActor ! AvatarActor.SaveLoadout(player, loadoutType, label, line) + case FavoritesAction.Delete => + avatarActor ! AvatarActor.DeleteLoadout(player, loadoutType, line) + case FavoritesAction.Unknown => + log.warn(s"FavoritesRequest: ${player.Name} requested an unknown favorites action") + } + } + + /** + * na + * @param tplayer na + * @param msg na + * @param order na + */ + def handle(tplayer: Player, msg: ItemTransactionMessage, order: Terminal.Exchange): Unit = { + order match { + case Terminal.BuyEquipment(item) + if tplayer.avatar.purchaseCooldown(item.Definition).nonEmpty => + sendResponse(ItemTransactionResultMessage(msg.terminal_guid, TransactionType.Buy, success = false)) + ops.lastTerminalOrderFulfillment = true + + case Terminal.BuyEquipment(item) => + avatarActor ! AvatarActor.UpdatePurchaseTime(item.Definition) + TaskWorkflow.execute(BuyNewEquipmentPutInInventory( + continent.GUID(tplayer.VehicleSeated) match { + case Some(v: Vehicle) => v + case _ => player + }, + tplayer, + msg.terminal_guid + )(item)) + + case Terminal.SellEquipment() => + SellEquipmentFromInventory(tplayer, tplayer, msg.terminal_guid)(Player.FreeHandSlot) + + case Terminal.LearnCertification(cert) => + avatarActor ! AvatarActor.LearnCertification(msg.terminal_guid, cert) + ops.lastTerminalOrderFulfillment = true + + case Terminal.SellCertification(cert) => + avatarActor ! AvatarActor.SellCertification(msg.terminal_guid, cert) + ops.lastTerminalOrderFulfillment = true + + case Terminal.LearnImplant(implant) => + avatarActor ! AvatarActor.LearnImplant(msg.terminal_guid, implant) + ops.lastTerminalOrderFulfillment = true + + case Terminal.SellImplant(implant) => + avatarActor ! AvatarActor.SellImplant(msg.terminal_guid, implant) + ops.lastTerminalOrderFulfillment = true + + case Terminal.BuyVehicle(vehicle, _, _) + if tplayer.avatar.purchaseCooldown(vehicle.Definition).nonEmpty => + sendResponse(ItemTransactionResultMessage(msg.terminal_guid, TransactionType.Buy, success = false)) + ops.lastTerminalOrderFulfillment = true + + case Terminal.BuyVehicle(vehicle, weapons, trunk) => + continent.map.terminalToSpawnPad + .find { case (termid, _) => termid == msg.terminal_guid.guid } + .map { case (a: Int, b: Int) => (continent.GUID(a), continent.GUID(b)) } + .collect { case (Some(term: Terminal), Some(pad: VehicleSpawnPad)) => + avatarActor ! AvatarActor.UpdatePurchaseTime(vehicle.Definition) + vehicle.Faction = tplayer.Faction + vehicle.Position = pad.Position + vehicle.Orientation = pad.Orientation + Vector3.z(pad.Definition.VehicleCreationZOrientOffset) + //default loadout, weapons + val vWeapons = vehicle.Weapons + weapons.foreach { entry => + vWeapons.get(entry.start) match { + case Some(slot) => + entry.obj.Faction = tplayer.Faction + slot.Equipment = None + slot.Equipment = entry.obj + case None => + log.warn( + s"BuyVehicle: ${player.Name} tries to apply default loadout to $vehicle on spawn, but can not find a mounted weapon for ${entry.start}" + ) + } + } + //default loadout, trunk + val vTrunk = vehicle.Trunk + vTrunk.Clear() + trunk.foreach { entry => + entry.obj.Faction = tplayer.Faction + vTrunk.InsertQuickly(entry.start, entry.obj) + } + TaskWorkflow.execute(ops.registerVehicleFromSpawnPad(vehicle, pad, term)) + sendResponse(ItemTransactionResultMessage(msg.terminal_guid, TransactionType.Buy, success = true)) + if (GlobalDefinitions.isBattleFrameVehicle(vehicle.Definition)) { + sendResponse(UnuseItemMessage(player.GUID, msg.terminal_guid)) + } + player.LogActivity(TerminalUsedActivity(AmenitySource(term), msg.transaction_type)) + } + .orElse { + log.error( + s"${tplayer.Name} wanted to spawn a vehicle, but there was no spawn pad associated with terminal ${msg.terminal_guid} to accept it" + ) + sendResponse(ItemTransactionResultMessage(msg.terminal_guid, TransactionType.Buy, success = false)) + None + } + ops.lastTerminalOrderFulfillment = true + + case Terminal.NoDeal() if msg != null => + val transaction = msg.transaction_type + log.warn(s"NoDeal: ${tplayer.Name} made a request but the terminal rejected the ${transaction.toString} order") + sendResponse(ItemTransactionResultMessage(msg.terminal_guid, transaction, success = false)) + ops.lastTerminalOrderFulfillment = true + + case _ => + val terminal = msg.terminal_guid.guid + continent.GUID(terminal) match { + case Some(term: Terminal) => + log.warn(s"NoDeal?: ${tplayer.Name} made a request but the ${term.Definition.Name}#$terminal rejected the missing order") + case Some(_) => + log.warn(s"NoDeal?: ${tplayer.Name} made a request to a non-terminal entity#$terminal") + case None => + log.warn(s"NoDeal?: ${tplayer.Name} made a request to a missing entity#$terminal") + } + ops.lastTerminalOrderFulfillment = true + } + } +} diff --git a/src/main/scala/net/psforever/actors/session/csr/VehicleHandlerLogic.scala b/src/main/scala/net/psforever/actors/session/csr/VehicleHandlerLogic.scala new file mode 100644 index 000000000..7bacfbda7 --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/csr/VehicleHandlerLogic.scala @@ -0,0 +1,399 @@ +// Copyright (c) 2024 PSForever +package net.psforever.actors.session.csr + +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/csr/VehicleLogic.scala b/src/main/scala/net/psforever/actors/session/csr/VehicleLogic.scala new file mode 100644 index 000000000..c9eac4b88 --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/csr/VehicleLogic.scala @@ -0,0 +1,355 @@ +// Copyright (c) 2024 PSForever +package net.psforever.actors.session.csr + +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.{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 + ops.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 + ops.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 => + //todo: old warning; this state is problematic, but can trigger in otherwise valid instances + //log.warn( + // s"ChildObjectState: ${player.Name} is using a different controllable agent than entity ${object_guid.guid}" + //) + case Some(_) => + //TODO set tool orientation? + player.Orientation = Vector3(0f, pitch, yaw) + continent.VehicleEvents ! VehicleServiceMessage( + continent.id, + VehicleAction.ChildObjectState(player.GUID, object_guid, pitch, yaw) + ) + } + //TODO status condition of "playing getting out of vehicle to allow for late packets without warning + 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") + .collect { + case 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 + ) + ) + } + } + + def handleDeployRequest(pkt: DeployRequestMessage): Unit = { + val DeployRequestMessage(_, vehicle_guid, deploy_state, _, _, _) = pkt + continent.GUID(vehicle_guid) + .collect { + case obj: Vehicle => + val vehicle = player.avatar.vehicle + if (!vehicle.contains(vehicle_guid)) { + log.warn(s"DeployRequest: ${player.Name} does not own the would-be-deploying ${obj.Definition.Name}") + } else if (vehicle != player.VehicleSeated) { + log.warn(s"${player.Name} must be mounted as the driver to request a deployment change") + } else { + log.info(s"${player.Name} is requesting a deployment change for ${obj.Definition.Name} - $deploy_state") + continent.Transport ! Zone.Vehicle.TryDeploymentChange(obj, deploy_state) + } + obj + case obj => + log.error(s"DeployRequest: ${player.Name} expected a vehicle, but found a ${obj.Definition.Name} instead") + obj + } + .orElse { + log.error(s"DeployRequest: ${player.Name} can not find entity $vehicle_guid") + avatarActor ! AvatarActor.SetVehicle(None) //todo is this safe + None + } + } + + /* messages */ + + def handleCanDeploy(obj: Deployment.DeploymentObject, state: DriveState.Value): Unit = { + if (state == DriveState.Deploying) { + log.trace(s"DeployRequest: $obj transitioning to deploy state") + } else if (state == DriveState.Deployed) { + log.trace(s"DeployRequest: $obj has been Deployed") + } else { + CanNotChangeDeployment(obj, state, "incorrect deploy state") + } + } + + def handleCanUndeploy(obj: Deployment.DeploymentObject, state: DriveState.Value): Unit = { + if (state == DriveState.Undeploying) { + log.trace(s"DeployRequest: $obj transitioning to undeploy state") + } else if (state == DriveState.Mobile) { + log.trace(s"DeployRequest: $obj is Mobile") + } else { + 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 */ + + /** + * 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/csr/WeaponAndProjectileLogic.scala b/src/main/scala/net/psforever/actors/session/csr/WeaponAndProjectileLogic.scala new file mode 100644 index 000000000..22cf517e3 --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/csr/WeaponAndProjectileLogic.scala @@ -0,0 +1,1379 @@ +// Copyright (c) 2024 PSForever +package net.psforever.actors.session.csr + +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.{CountAmmunition, CountGrenades, FindAmmoBoxThatUses, FindEquipmentStock, FindToolThatUses, PutEquipmentInInventoryOrDrop, PutNewEquipmentInInventoryOrDrop, RemoveOldEquipmentFromInventory} +import net.psforever.objects.ballistics.{Projectile, ProjectileQuality} +import net.psforever.objects.definition.ProjectileDefinition +import net.psforever.objects.entity.SimpleWorldEntity +import net.psforever.objects.equipment.{ChargeFireModeDefinition, Equipment, EquipmentSize, FireModeSwitch} +import net.psforever.objects.guid.{GUIDTask, TaskBundle, TaskWorkflow} +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, ConstructionItem, Deployables, DummyExplodingEntity, GlobalDefinitions, OwnableByPlayer, PlanetSideGameObject, Player, SpecialEmp, Tool, Tools, Vehicle} +import net.psforever.objects.serverobject.interior.Sidedness +import net.psforever.objects.serverobject.mount.Mountable +import net.psforever.objects.serverobject.structures.Amenity +import net.psforever.objects.serverobject.turret.{FacilityTurret, VanuSentry} +import net.psforever.objects.serverobject.turret.auto.{AutomatedTurret, AutomatedTurretBehavior} +import net.psforever.objects.sourcing.{PlayerSource, 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, 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} +import net.psforever.util.Config + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +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 = { + val WeaponFireMessage( + _, + weapon_guid, + projectile_guid, + shot_origin, + _, + _, + _, + _/*max_distance,*/, + _, + _/*projectile_type,*/, + thrown_projectile_vel + ) = pkt + HandleWeaponFireOperations(weapon_guid, projectile_guid, shot_origin, thrown_projectile_vel.flatten) + } + + def handleWeaponDelayFire(pkt: WeaponDelayFireMessage): Unit = { + val WeaponDelayFireMessage(_, _) = pkt + log.info(s"${player.Name} - $pkt") + } + + def handleWeaponDryFire(pkt: WeaponDryFireMessage): Unit = { + val WeaponDryFireMessage(weapon_guid) = pkt + val (containerOpt, tools) = ops.FindContainedWeapon + tools + .find { _.GUID == weapon_guid } + .orElse { continent.GUID(weapon_guid) } + .collect { + case _: Equipment if containerOpt.exists(_.isInstanceOf[Player]) => + continent.AvatarEvents ! AvatarServiceMessage( + continent.id, + AvatarAction.WeaponDryFire(player.GUID, weapon_guid) + ) + case _: Equipment => + continent.VehicleEvents ! VehicleServiceMessage( + continent.id, + VehicleAction.WeaponDryFire(player.GUID, weapon_guid) + ) + } + .orElse { + log.warn( + s"WeaponDryFire: ${player.Name}'s weapon ${weapon_guid.guid} is either not a weapon or does not exist" + ) + None + } + } + + def handleWeaponLazeTargetPosition(pkt: WeaponLazeTargetPositionMessage): Unit = { + val WeaponLazeTargetPositionMessage(_, _, _) = pkt + //do not need to handle the progress bar animation/state on the server + //laze waypoint is requested by client upon completion (see SquadWaypointRequest) + val purpose = if (sessionLogic.squad.squad_supplement_id > 0) { + s" for ${player.Sex.possessive} squad (#${sessionLogic.squad.squad_supplement_id -1})" + } else { + " ..." + } + 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? + log.info(s"${player.Name} has $state ${player.Sex.possessive} grenade") + } + + def handleChangeFireStateStart(pkt: ChangeFireStateMessage_Start): Unit = { + val ChangeFireStateMessage_Start(item_guid) = pkt + if (ops.shooting.isEmpty) { + sessionLogic.findEquipment(item_guid) match { + case Some(tool: Tool) if player.VehicleSeated.isEmpty => + fireStateStartWhenPlayer(tool, item_guid) + case Some(tool: Tool) => + fireStateStartWhenMounted(tool, item_guid) + case Some(_) if player.VehicleSeated.isEmpty => + fireStateStartSetup(item_guid) + fireStateStartPlayerMessages(item_guid) + case Some(_) => + fireStateStartSetup(item_guid) + fireStateStartMountedMessages(item_guid) + case None => + log.warn(s"ChangeFireState_Start: can not find $item_guid") + } + } + } + + 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 _ => + log.warn(s"ChangeFireState_Stop: can not find $item_guid") + } + sessionLogic.general.progressBarUpdate.cancel() + sessionLogic.general.progressBarValue = None + } + + def handleReload(pkt: ReloadMessage): Unit = { + val ReloadMessage(item_guid, _, unk1) = pkt + ops.FindContainedWeapon match { + case (Some(obj: Player), tools) => + handleReloadWhenPlayer(item_guid, obj, tools, unk1) + case (Some(obj: PlanetSideServerObject with Container), tools) => + handleReloadWhenMountable(item_guid, obj, tools, unk1) + case (_, _) => + log.warn(s"ReloadMessage: either can not find $item_guid or the object found was not a Tool") + } + } + + def handleChangeAmmo(pkt: ChangeAmmoMessage): Unit = { + val ChangeAmmoMessage(item_guid, _) = pkt + val (thing, equipment) = sessionLogic.findContainedEquipment() + if (equipment.isEmpty) { + log.warn(s"ChangeAmmo: either can not find $item_guid or the object found was not Equipment") + } else { + equipment foreach { + case obj: ConstructionItem => + if (Deployables.performConstructionItemAmmoChange(player.avatar.certifications, obj, obj.AmmoTypeIndex)) { + log.info( + s"${player.Name} switched ${player.Sex.possessive} ${obj.Definition.Name} to construct ${obj.AmmoType} (option #${obj.FireModeIndex})" + ) + sendResponse(ChangeAmmoMessage(obj.GUID, obj.AmmoTypeIndex)) + } + case tool: Tool => + thing match { + case Some(player: Player) => + PerformToolAmmoChange(tool, player, ModifyAmmunition(player)) + case Some(mountable: PlanetSideServerObject with Container) => + PerformToolAmmoChange(tool, mountable, ModifyAmmunitionInMountable(mountable)) + case _ => + log.warn(s"ChangeAmmo: the ${thing.get.Definition.Name} in ${player.Name}'s is not the correct type") + } + case obj => + log.warn(s"ChangeAmmo: the ${obj.Definition.Name} in ${player.Name}'s hands does not contain ammunition") + } + } + } + + def handleChangeFireMode(pkt: ChangeFireModeMessage): Unit = { + val ChangeFireModeMessage(item_guid, _/*fire_mode*/) = pkt + sessionLogic.findEquipment(item_guid) match { + case Some(obj: PlanetSideGameObject with FireModeSwitch[_]) => + val originalModeIndex = obj.FireModeIndex + if (obj match { + case citem: ConstructionItem => + val modeChanged = Deployables.performConstructionItemFireModeChange( + player.avatar.certifications, + citem, + originalModeIndex + ) + modeChanged + case _ => + obj.NextFireMode + obj.FireModeIndex != originalModeIndex + }) { + val modeIndex = obj.FireModeIndex + obj match { + case citem: ConstructionItem => + log.info(s"${player.Name} switched ${player.Sex.possessive} ${obj.Definition.Name} to construct ${citem.AmmoType} (mode #$modeIndex)") + case _ => + log.info(s"${player.Name} changed ${player.Sex.possessive} her ${obj.Definition.Name}'s fire mode to #$modeIndex") + } + sendResponse(ChangeFireModeMessage(item_guid, modeIndex)) + continent.AvatarEvents ! AvatarServiceMessage( + continent.id, + AvatarAction.ChangeFireMode(player.GUID, item_guid, modeIndex) + ) + } + case Some(_) => + log.warn(s"ChangeFireMode: the object that was found for $item_guid does not possess fire modes") + case None => + log.warn(s"ChangeFireMode: can not find $item_guid") + } + } + + 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 = { + val LongRangeProjectileInfoMessage(guid, _, _) = pkt + ops.FindContainedWeapon match { + case (Some(_: Vehicle), weapons) + if weapons.exists { _.GUID == guid } => () //now what? + case _ => () + } + } + + def handleDirectHit(pkt: HitMessage): Unit = { + val HitMessage( + _, + projectile_guid, + _, + hit_info, + _, + _, + _ + ) = pkt + //find defined projectile + ops.FindProjectileEntry(projectile_guid) match { + case Some(projectile) => + //find target(s) + (hit_info match { + case Some(hitInfo) => + val hitPos = hitInfo.hit_pos + sessionLogic.validObject(hitInfo.hitobject_guid, decorator = "Hit/hitInfo") match { + case _ if projectile.profile == GlobalDefinitions.flail_projectile => + val radius = projectile.profile.DamageRadius * projectile.profile.DamageRadius + val targets = Zone.findAllTargets(continent, player, hitPos, projectile.profile) + .filter { target => + Vector3.DistanceSquared(target.Position, hitPos) <= radius + } + targets.map { target => + CheckForHitPositionDiscrepancy(projectile_guid, hitPos, target) + (target, projectile, hitPos, target.Position) + } + + case Some(target: PlanetSideGameObject with FactionAffinity with Vitality) => + CheckForHitPositionDiscrepancy(projectile_guid, hitPos, target) + List((target, projectile, hitInfo.shot_origin, hitPos)) + + case None => + HandleDamageProxy(projectile, projectile_guid, hitPos) + + case _ => + Nil + } + case None => + Nil + }) + .foreach { + case ( + target: PlanetSideGameObject with FactionAffinity with Vitality, + proj: Projectile, + _: Vector3, + hitPos: Vector3 + ) => + ResolveProjectileInteraction(proj, DamageResolution.Hit, target, hitPos).collect { resprojectile => + addShotsLanded(resprojectile.cause.attribution, shots = 1) + sessionLogic.handleDealingDamage(target, resprojectile) + } + case _ => () + } + case None => + log.warn(s"ResolveProjectile: expected projectile, but ${projectile_guid.guid} not found") + } + } + + 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 + continent.Projectile ! ZoneProjectile.Remove(projectile.GUID) + } + case None => () + } + } + + def handleLashHit(pkt: LashMessage): Unit = { + val LashMessage(_, _, victim_guid, projectile_guid, hit_pos, _) = pkt + sessionLogic.validObject(victim_guid, decorator = "Lash") match { + case Some(target: PlanetSideGameObject with FactionAffinity with Vitality) => + CheckForHitPositionDiscrepancy(projectile_guid, hit_pos, target) + ResolveProjectileInteraction(projectile_guid, DamageResolution.Lash, target, hit_pos).foreach { + resprojectile => + addShotsLanded(resprojectile.cause.attribution, shots = 1) + sessionLogic.handleDealingDamage(target, resprojectile) + } + case _ => () + } + } + + 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, 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, projectileTypeId)) + } + Some(target) + } + } + } + + /* support code */ + + private def HandleWeaponFireOperations( + weaponGUID: PlanetSideGUID, + projectileGUID: PlanetSideGUID, + shotOrigin: Vector3, + shotVelocity: Option[Vector3] + ): Unit = { + ops.HandleWeaponFireAccountability(weaponGUID, projectileGUID) match { + case (Some(obj), Some(tool)) => + val projectileIndex = projectileGUID.guid - Projectile.baseUID + val projectilePlace = ops.projectiles(projectileIndex) + if ( + projectilePlace match { + case Some(projectile) => + !projectile.isResolved && System.currentTimeMillis() - projectile.fire_time < projectile.profile.Lifespan.toLong + case None => + false + } + ) { + log.debug( + s"WeaponFireMessage: overwriting unresolved projectile ${projectileGUID.guid}, known to ${player.Name}" + ) + } + val (angle, attribution, acceptableDistanceToOwner) = obj match { + case p: Player => + ( + SimpleWorldEntity.validateOrientationEntry( + p.Orientation + Vector3.z(p.FacingYawUpper) + ), + tool.Definition.ObjectId, + 10f + (if (p.Velocity.nonEmpty) { + 5f + } else { + 0f + }) + ) + case v: Vehicle if v.Definition.CanFly => + (tool.Orientation, obj.Definition.ObjectId, 1000f) //TODO this is too simplistic to find proper angle + case _: Vehicle => + (tool.Orientation, obj.Definition.ObjectId, 225f) //TODO this is too simplistic to find proper angle + case _ => + (obj.Orientation, obj.Definition.ObjectId, 300f) + } + val distanceToOwner = Vector3.DistanceSquared(shotOrigin, player.Position) + if (distanceToOwner <= acceptableDistanceToOwner) { + val projectile_info = tool.Projectile + val wguid = weaponGUID.guid + val mountedIn = (continent.turretToWeapon + .find { case (guid, _) => guid == wguid } match { + case Some((_, turretGuid)) => Some(( + turretGuid, + continent.GUID(turretGuid).collect { case o: PlanetSideGameObject with FactionAffinity => SourceEntry(o) } + )) + case _ => None + }) match { + case Some((guid, Some(entity))) => Some((guid, entity)) + case _ => None + } + val projectile = new Projectile( + projectile_info, + tool.Definition, + tool.FireMode, + mountedIn, + PlayerSource(player), + attribution, + shotOrigin, + angle, + shotVelocity + ) + val initialQuality = tool.FireMode match { + case mode: ChargeFireModeDefinition => + ProjectileQuality.Modified( + { + val timeInterval = projectile.fire_time - ops.shootingStart.getOrElse(tool.GUID, System.currentTimeMillis()) + timeInterval.toFloat / mode.Time.toFloat + } + ) + case _ => + ProjectileQuality.Normal + } + val qualityprojectile = projectile.quality(initialQuality) + qualityprojectile.WhichSide = player.WhichSide + ops.projectiles(projectileIndex) = Some(qualityprojectile) + if (projectile_info.ExistsOnRemoteClients) { + log.trace( + s"WeaponFireMessage: ${player.Name}'s ${projectile_info.Name} is a remote projectile" + ) + continent.Projectile ! ZoneProjectile.Add(player.GUID, qualityprojectile) + } + } else { + log.warn( + s"WeaponFireMessage: ${player.Name}'s ${tool.Definition.Name} projectile is too far from owner position at time of discharge ($distanceToOwner > $acceptableDistanceToOwner); suspect" + ) + } + + case _ => () + } + } + + /** + * 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)) + } + + /** + * Given a vehicle that contains a box of ammunition in its `Trunk` 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 ModifyAmmunitionInMountable(obj: PlanetSideServerObject with Container)(box: AmmoBox, reloadValue: Int): Unit = { + ModifyAmmunition(obj)(box, reloadValue) + obj.Find(box).collect { index => + continent.VehicleEvents ! VehicleServiceMessage( + s"${obj.Actor}", + VehicleAction.InventoryState( + player.GUID, + box, + obj.GUID, + index, + box.Definition.Packet.DetailedConstructorData(box).get + ) + ) + } + } + + /** + * na + * @param tool na + * @param obj na + */ + private def PerformToolAmmoChange( + tool: Tool, + obj: PlanetSideServerObject with Container, + modifyFunc: (AmmoBox, Int) => Unit + ): Unit = { + val originalAmmoType = tool.AmmoType + do { + val requestedAmmoType = tool.NextAmmoType + val fullMagazine = tool.MaxMagazine + if (requestedAmmoType != tool.AmmoSlot.Box.AmmoType) { + FindEquipmentStock(obj, FindAmmoBoxThatUses(requestedAmmoType), fullMagazine, CountAmmunition).reverse match { + case Nil => () + case x :: xs => + val stowNewFunc: Equipment => TaskBundle = PutNewEquipmentInInventoryOrDrop(obj) + val stowFunc: Equipment => Future[Any] = PutEquipmentInInventoryOrDrop(obj) + + xs.foreach(item => { + obj.Inventory -= item.start + sendResponse(ObjectDeleteMessage(item.obj.GUID, 0)) + TaskWorkflow.execute(GUIDTask.unregisterObject(continent.GUID, item.obj)) + }) + + //box will be the replacement ammo; give it the discovered magazine and load it into the weapon + val box = x.obj.asInstanceOf[AmmoBox] + //previousBox is the current magazine in tool; it will be removed from the weapon + val previousBox = tool.AmmoSlot.Box + val originalBoxCapacity = box.Capacity + val tailReloadValue: Int = if (xs.isEmpty) { + 0 + } else { + xs.map(_.obj.asInstanceOf[AmmoBox].Capacity).sum + } + val sumReloadValue: Int = originalBoxCapacity + tailReloadValue + val ammoSlotIndex = tool.FireMode.AmmoSlotIndex + val box_guid = box.GUID + val tool_guid = tool.GUID + obj.Inventory -= x.start //remove replacement ammo from inventory + tool.AmmoSlots(ammoSlotIndex).Box = box //put replacement ammo in tool + sendResponse(ObjectDetachMessage(tool_guid, previousBox.GUID, Vector3.Zero, 0f)) + sendResponse(ObjectDetachMessage(obj.GUID, box_guid, Vector3.Zero, 0f)) + sendResponse(ObjectAttachMessage(tool_guid, box_guid, ammoSlotIndex)) + + //announce swapped ammunition box in weapon + val previous_box_guid = previousBox.GUID + val boxDef = box.Definition + sendResponse(ChangeAmmoMessage(tool_guid, box.Capacity)) + continent.AvatarEvents ! AvatarServiceMessage( + continent.id, + AvatarAction.ChangeAmmo( + player.GUID, + tool_guid, + ammoSlotIndex, + previous_box_guid, + boxDef.ObjectId, + box.GUID, + boxDef.Packet.ConstructorData(box).get + ) + ) + + //handle inventory contents + box.Capacity = if (sumReloadValue <= fullMagazine) { + sumReloadValue + } else { + val splitReloadAmmo: Int = sumReloadValue - fullMagazine + log.trace( + s"PerformToolAmmoChange: ${player.Name} takes ${originalBoxCapacity - splitReloadAmmo} from a box of $originalBoxCapacity $requestedAmmoType ammo" + ) + val boxForInventory = AmmoBox(box.Definition, splitReloadAmmo) + TaskWorkflow.execute(stowNewFunc(boxForInventory)) + fullMagazine + } + sendResponse( + InventoryStateMessage(box.GUID, tool.GUID, box.Capacity) + ) //should work for both players and vehicles + log.info(s"${player.Name} loads ${box.Capacity} $requestedAmmoType into the ${tool.Definition.Name}") + if (previousBox.Capacity > 0) { + //divide capacity across other existing and not full boxes of that ammo type + var capacity = previousBox.Capacity + val iter = obj.Inventory.Items + .filter(entry => { + entry.obj match { + case item: AmmoBox => + item.AmmoType == originalAmmoType && item.FullCapacity != item.Capacity + case _ => + false + } + }) + .sortBy(_.start) + .iterator + while (capacity > 0 && iter.hasNext) { + val entry = iter.next() + val item: AmmoBox = entry.obj.asInstanceOf[AmmoBox] + val ammoAllocated = math.min(item.FullCapacity - item.Capacity, capacity) + log.info(s"${player.Name} put $ammoAllocated back into a box of ${item.Capacity} $originalAmmoType") + capacity -= ammoAllocated + modifyFunc(item, -ammoAllocated) + } + previousBox.Capacity = capacity + } + + if (previousBox.Capacity > 0) { + //split previousBox into AmmoBox objects of appropriate max capacity, e.g., 100 9mm -> 2 x 50 9mm + obj.Inventory.Fit(previousBox) match { + case Some(_) => + stowFunc(previousBox) + case None => + sessionLogic.general.normalItemDrop(player, continent)(previousBox) + } + AmmoBox.Split(previousBox) match { + case Nil | List(_) => () //done (the former case is technically not possible) + case _ :: toUpdate => + modifyFunc(previousBox, 0) //update to changed capacity value + toUpdate.foreach(box => { TaskWorkflow.execute(stowNewFunc(box)) }) + } + } else { + TaskWorkflow.execute(GUIDTask.unregisterObject(continent.GUID, previousBox)) + } + } + } + } while (tool.AmmoType != originalAmmoType && tool.AmmoType != tool.AmmoSlot.Box.AmmoType) + } + + 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" + ) + } + } + + /** + * Find a projectile with the given globally unique identifier and mark it as a resolved shot. + * A `Resolved` shot has either encountered an obstacle or is being cleaned up for not finding an obstacle. + * @param projectile_guid the projectile GUID + * @param resolution the resolution status to promote the projectile + * @return the projectile + */ + private def ResolveProjectileInteraction( + projectile_guid: PlanetSideGUID, + resolution: DamageResolution.Value, + target: PlanetSideGameObject with FactionAffinity with Vitality, + pos: Vector3 + ): Option[DamageInteraction] = { + ops.FindProjectileEntry(projectile_guid) match { + case Some(projectile) => + ResolveProjectileInteraction(projectile, resolution, target, pos) + case None => + log.trace(s"ResolveProjectile: ${player.Name} expected projectile, but ${projectile_guid.guid} not found") + None + } + } + + /** + * 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() } + } + + /* + used by ChangeFireStateMessage_Start handling + */ + private def fireStateStartSetup(itemGuid: PlanetSideGUID): Unit = { + ops.prefire -= itemGuid + ops.shooting += itemGuid + ops.shootingStart += itemGuid -> System.currentTimeMillis() + } + + private def fireStateStartChargeMode(tool: Tool): Unit = { + //charge ammunition drain + tool.FireMode match { + case mode: ChargeFireModeDefinition => + sessionLogic.general.progressBarValue = Some(0f) + sessionLogic.general.progressBarUpdate = context.system.scheduler.scheduleOnce( + (mode.Time + mode.DrainInterval) milliseconds, + context.self, + CommonMessages.ProgressEvent(1f, () => {}, Tools.ChargeFireMode(player, tool), mode.DrainInterval) + ) + case _ => () + } + } + + private def fireStateStartPlayerMessages(itemGuid: PlanetSideGUID): Unit = { + continent.AvatarEvents ! AvatarServiceMessage( + continent.id, + AvatarAction.ChangeFireState_Start(player.GUID, itemGuid) + ) + } + + private def fireStateStartMountedMessages(itemGuid: PlanetSideGUID): Unit = { + sessionLogic.findContainedEquipment()._1.collect { + case turret: FacilityTurret if continent.map.cavern => + turret.Actor ! VanuSentry.ChangeFireStart + } + continent.VehicleEvents ! VehicleServiceMessage( + continent.id, + VehicleAction.ChangeFireState_Start(player.GUID, itemGuid) + ) + } + + private def allowFireStateChangeStart(tool: Tool, itemGuid: PlanetSideGUID): Boolean = { + tool.FireMode.RoundsPerShot == 0 || tool.Magazine > 0 || ops.prefire.contains(itemGuid) + } + + private def enforceEmptyMagazine(tool: Tool, itemGuid: PlanetSideGUID): Unit = { + log.warn( + s"ChangeFireState_Start: ${player.Name}'s ${tool.Definition.Name} magazine was empty before trying to shoot" + ) + ops.EmptyMagazine(itemGuid, tool) + } + + private def fireStateStartWhenPlayer(tool: Tool, itemGuid: PlanetSideGUID): Unit = { + if (allowFireStateChangeStart(tool, itemGuid)) { + fireStateStartSetup(itemGuid) + //special case - suppress the decimator's alternate fire mode, by projectile + if (tool.Projectile != GlobalDefinitions.phoenix_missile_guided_projectile) { + fireStateStartPlayerMessages(itemGuid) + } + fireStateStartChargeMode(tool) + } else { + enforceEmptyMagazine(tool, itemGuid) + } + } + + private def fireStateStartWhenMounted(tool: Tool, itemGuid: PlanetSideGUID): Unit = { + if (allowFireStateChangeStart(tool, itemGuid)) { + fireStateStartSetup(itemGuid) + fireStateStartMountedMessages(itemGuid) + fireStateStartChargeMode(tool) + } else { + enforceEmptyMagazine(tool, 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) + } + + /* + used by ReloadMessage handling + */ + private def reloadPlayerMessages(itemGuid: PlanetSideGUID): Unit = { + continent.AvatarEvents ! AvatarServiceMessage( + continent.id, + AvatarAction.Reload(player.GUID, itemGuid) + ) + } + + private def reloadVehicleMessages(itemGuid: PlanetSideGUID): Unit = { + continent.VehicleEvents ! VehicleServiceMessage( + continent.id, + VehicleAction.Reload(player.GUID, itemGuid) + ) + } + + private def handleReloadProcedure( + itemGuid: PlanetSideGUID, + obj: PlanetSideGameObject with Container, + tools: Set[Tool], + unk1: Int, + deleteFunc: Equipment => Future[Any], + modifyFunc: (AmmoBox, Int) => Unit, + messageFunc: PlanetSideGUID => Unit + ): Unit = { + tools + .filter { _.GUID == itemGuid } + .foreach { tool => + val currentMagazine : Int = tool.Magazine + val magazineSize : Int = tool.MaxMagazine + val reloadValue : Int = magazineSize - currentMagazine + if (magazineSize > 0 && reloadValue > 0) { + FindEquipmentStock(obj, FindAmmoBoxThatUses(tool.AmmoType), reloadValue, CountAmmunition).reverse match { + case Nil => () + case x :: xs => + xs.foreach { item => deleteFunc(item.obj) } + val box = x.obj.asInstanceOf[AmmoBox] + val tailReloadValue : Int = if (xs.isEmpty) { + 0 + } + else { + xs.map(_.obj.asInstanceOf[AmmoBox].Capacity).sum + } + val sumReloadValue : Int = box.Capacity + tailReloadValue + val actualReloadValue = if (sumReloadValue <= reloadValue) { + deleteFunc(box) + sumReloadValue + } + else { + modifyFunc(box, reloadValue - tailReloadValue) + reloadValue + } + val finalReloadValue = actualReloadValue + currentMagazine + log.info( + s"${player.Name} successfully reloaded $reloadValue ${tool.AmmoType} into ${tool.Definition.Name}" + ) + tool.Magazine = finalReloadValue + sendResponse(ReloadMessage(itemGuid, finalReloadValue, unk1)) + messageFunc(itemGuid) + } + } else { + //the weapon can not reload due to full magazine; the UI for the magazine is obvious bugged, so fix it + sendResponse(QuantityUpdateMessage(tool.AmmoSlot.Box.GUID, magazineSize)) + } + } + } + + private def handleReloadWhenPlayer( + itemGuid: PlanetSideGUID, + obj: Player, + tools: Set[Tool], + unk1: Int + ): Unit = { + handleReloadProcedure( + itemGuid, + obj, + tools, + unk1, + RemoveOldEquipmentFromInventory(obj)(_), + ModifyAmmunition(obj)(_, _), + reloadPlayerMessages + ) + } + + private def handleReloadWhenMountable( + itemGuid: PlanetSideGUID, + obj: PlanetSideServerObject with Container, + tools: Set[Tool], + unk1: Int + ): Unit = { + handleReloadProcedure( + itemGuid, + obj, + tools, + unk1, + RemoveOldEquipmentFromInventory(obj)(_), + ModifyAmmunitionInMountable(obj)(_, _), + reloadVehicleMessages + ) + } + + //noinspection SameParameterValue + private def addShotsLanded(weaponId: Int, shots: Int): Unit = { + ops.addShotsToMap(ops.shotsLanded, weaponId, shots) + } + + private def CompileAutomatedTurretDamageData( + turret: AutomatedTurret, + projectileTypeId: Long + ): Option[(AutomatedTurret, Tool, SourceEntry, ProjectileDefinition)] = { + turret match { + case tOwner: OwnableByPlayer => + CompileAutomatedTurretDamageData( + turret, + CompileAutomatedTurretOwnableBlame(tOwner), + projectileTypeId + ) + case tAmenity: Amenity => + CompileAutomatedTurretDamageData( + turret, + CompileAutomatedTurretAmenityBlame(tAmenity), + projectileTypeId + ) + case _ => + None + } + } + + private def CompileAutomatedTurretOwnableBlame(turret: AutomatedTurret with OwnableByPlayer): SourceEntry = { + Deployables.AssignBlameTo(continent, turret.OwnerName, SourceEntry(turret)) + } + + private def CompileAutomatedTurretAmenityBlame(turret: AutomatedTurret with Amenity): SourceEntry = { + turret + .Seats + .values + .flatMap(_.occupant) + .collectFirst(SourceEntry(_)) + .getOrElse(SourceEntry(turret.Owner)) + } + + private def CompileAutomatedTurretDamageData( + turret: AutomatedTurret, + blame: SourceEntry, + projectileTypeId: Long + ): Option[(AutomatedTurret, Tool, SourceEntry, ProjectileDefinition)] = { + turret.Weapons + .values + .flatMap { _.Equipment } + .collect { + case weapon: Tool => (turret, weapon, blame, 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/normal/ChatLogic.scala b/src/main/scala/net/psforever/actors/session/normal/ChatLogic.scala index e701c0a55..961845ff9 100644 --- a/src/main/scala/net/psforever/actors/session/normal/ChatLogic.scala +++ b/src/main/scala/net/psforever/actors/session/normal/ChatLogic.scala @@ -2,13 +2,12 @@ package net.psforever.actors.session.normal import akka.actor.ActorContext +import net.psforever.actors.session.spectator.SpectatorMode import net.psforever.actors.session.support.{ChatFunctions, ChatOperations, SessionData} import net.psforever.objects.Session -import net.psforever.objects.avatar.ModePermissions import net.psforever.packet.game.{ChatMsg, SetChatFilterMessage} import net.psforever.services.chat.DefaultChannel import net.psforever.types.ChatMessageType -import net.psforever.util.Config object ChatLogic { def apply(ops: ChatOperations): ChatLogic = { @@ -19,34 +18,27 @@ object ChatLogic { class ChatLogic(val ops: ChatOperations, implicit val context: ActorContext) extends ChatFunctions { def sessionLogic: SessionData = ops.sessionLogic + ops.SpectatorMode = SpectatorMode + def handleChatMsg(message: ChatMsg): Unit = { import net.psforever.types.ChatMessageType._ val isAlive = if (player != null) player.isAlive else false - val perms = if (avatar != null) avatar.permissions else ModePermissions() - val gmCommandAllowed = (session.account.gm && perms.canGM) || - Config.app.development.unprivilegedGmCommands.contains(message.messageType) (message.messageType, message.recipient.trim, message.contents.trim) match { /** Messages starting with ! are custom chat commands */ case (_, _, contents) if contents.startsWith("!") && customCommandMessages(message, session) => () - case (CMT_FLY, recipient, contents) if gmCommandAllowed => - ops.commandFly(contents, recipient) - case (CMT_ANONYMOUS, _, _) => // ? - case (CMT_TOGGLE_GM, _, _) => - // ? + case (CMT_TOGGLE_GM, _, contents) => + ops.customCommandModerator(contents) case (CMT_CULLWATERMARK, _, contents) => ops.commandWatermark(contents) - case (CMT_SPEED, _, contents) if gmCommandAllowed => - ops.commandSpeed(message, contents) - - case (CMT_TOGGLESPECTATORMODE, _, contents) if isAlive && (gmCommandAllowed || perms.canSpectate) => - ops.commandToggleSpectatorMode(session, contents) + case (CMT_TOGGLESPECTATORMODE, _, contents) if isAlive => + ops.commandToggleSpectatorMode(contents) case (CMT_RECALL, _, _) => ops.commandRecall(session) @@ -63,28 +55,6 @@ class ChatLogic(val ops: ChatOperations, implicit val context: ActorContext) ext case (CMT_DESTROY, _, contents) if contents.matches("\\d+") => ops.commandDestroy(session, message, contents) - case (CMT_SETBASERESOURCES, _, contents) if gmCommandAllowed => - ops.commandSetBaseResources(session, contents) - - case (CMT_ZONELOCK, _, contents) if gmCommandAllowed => - ops.commandZoneLock(contents) - - case (U_CMT_ZONEROTATE, _, _) if gmCommandAllowed => - ops.commandZoneRotate() - - case (CMT_CAPTUREBASE, _, contents) if gmCommandAllowed => - ops.commandCaptureBase(session, message, contents) - - case (CMT_GMBROADCAST | CMT_GMBROADCAST_NC | CMT_GMBROADCAST_VS | CMT_GMBROADCAST_TR, _, _) - if gmCommandAllowed => - ops.commandSendToRecipient(session, message, DefaultChannel) - - case (CMT_GMTELL, _, _) if gmCommandAllowed => - ops.commandSend(session, message, DefaultChannel) - - case (CMT_GMBROADCASTPOPUP, _, _) if gmCommandAllowed => - ops.commandSendToRecipient(session, message, DefaultChannel) - case (CMT_OPEN, _, _) if !player.silenced => ops.commandSendToRecipient(session, message, DefaultChannel) @@ -100,54 +70,21 @@ class ChatLogic(val ops: ChatOperations, implicit val context: ActorContext) ext case (CMT_PLATOON, _, _) if !player.silenced => ops.commandSendToRecipient(session, message, DefaultChannel) - case (CMT_COMMAND, _, _) if gmCommandAllowed => - ops.commandSendToRecipient(session, message, DefaultChannel) - case (CMT_NOTE, _, _) => ops.commandSend(session, message, DefaultChannel) - case (CMT_SILENCE, _, _) if gmCommandAllowed => - ops.commandSend(session, message, DefaultChannel) - case (CMT_SQUAD, _, _) => ops.commandSquad(session, message, DefaultChannel) //todo SquadChannel, but what is the guid case (CMT_WHO | CMT_WHO_CSR | CMT_WHO_CR | CMT_WHO_PLATOONLEADERS | CMT_WHO_SQUADLEADERS | CMT_WHO_TEAMS, _, _) => ops.commandWho(session) - case (CMT_ZONE, _, contents) if gmCommandAllowed => - ops.commandZone(message, contents) - - case (CMT_WARP, _, contents) if gmCommandAllowed => - ops.commandWarp(session, message, contents) - - case (CMT_SETBATTLERANK, _, contents) if gmCommandAllowed => - ops.commandSetBattleRank(session, message, contents) - - case (CMT_SETCOMMANDRANK, _, contents) if gmCommandAllowed => - ops.commandSetCommandRank(session, message, contents) - - case (CMT_ADDBATTLEEXPERIENCE, _, contents) if gmCommandAllowed => - ops.commandAddBattleExperience(message, contents) - - case (CMT_ADDCOMMANDEXPERIENCE, _, contents) if gmCommandAllowed => - ops.commandAddCommandExperience(message, contents) - case (CMT_TOGGLE_HAT, _, contents) => ops.commandToggleHat(session, message, contents) case (CMT_HIDE_HELMET | CMT_TOGGLE_SHADES | CMT_TOGGLE_EARPIECE, _, contents) => ops.commandToggleCosmetics(session, message, contents) - - case (CMT_ADDCERTIFICATION, _, contents) if gmCommandAllowed => - ops.commandAddCertification(session, message, contents) - - case (CMT_KICK, _, contents) if gmCommandAllowed => - ops.commandKick(session, message, contents) - - case (CMT_REPORTUSER, _, contents) => - ops.commandReportUser(session, message, contents) - + case _ => sendResponse(ChatMsg(ChatMessageType.UNK_227, "@no_permission")) } @@ -192,51 +129,28 @@ class ChatLogic(val ops: ChatOperations, implicit val context: ActorContext) ext case a :: b => (a, b) case _ => ("", Seq("")) } - val perms = if (avatar != null) avatar.permissions else ModePermissions() - val gmBangCommandAllowed = (session.account.gm && perms.canGM) || - Config.app.development.unprivilegedGmBangCommands.contains(command) - //try gm commands - val tryGmCommandResult = if (gmBangCommandAllowed) { - command match { - case "whitetext" => Some(ops.customCommandWhitetext(session, params)) - case "list" => Some(ops.customCommandList(session, params, message)) - case "ntu" => Some(ops.customCommandNtu(session, params)) - case "zonerotate" => Some(ops.customCommandZonerotate(params)) - case "nearby" => Some(ops.customCommandNearby(session)) - case _ => None - } - } else { - None - } - //try commands for all players if not caught as a gm command - val result = tryGmCommandResult match { - case None => - command match { - case "loc" => ops.customCommandLoc(session, message) - case "suicide" => ops.customCommandSuicide(session) - case "grenade" => ops.customCommandGrenade(session, log) - case "macro" => ops.customCommandMacro(session, params) - case "progress" => ops.customCommandProgress(session, params) - case _ => false - } - case Some(out) => - out - } - if (!result) { - // command was not handled - sendResponse( - ChatMsg( - ChatMessageType.CMT_GMOPEN, // CMT_GMTELL - message.wideContents, - "Server", - s"Unknown command !$command", - message.note + command match { + case "loc" => ops.customCommandLoc(session, message) + case "suicide" => ops.customCommandSuicide(session) + case "grenade" => ops.customCommandGrenade(session, log) + case "macro" => ops.customCommandMacro(session, params) + case "progress" => ops.customCommandProgress(session, params) + case "csr" | "gm" | "op" => ops.customCommandModerator(params.headOption.getOrElse("")) + case _ => + // command was not handled + sendResponse( + ChatMsg( + ChatMessageType.CMT_GMOPEN, // CMT_GMTELL + message.wideContents, + "Server", + s"Unknown command !$command", + message.note + ) ) - ) + false } - result } else { - false // not a handled command + false } } } 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 ab40f6222..a19a401a0 100644 --- a/src/main/scala/net/psforever/actors/session/normal/GeneralLogic.scala +++ b/src/main/scala/net/psforever/actors/session/normal/GeneralLogic.scala @@ -292,8 +292,7 @@ class GeneralLogic(val ops: GeneralOperations, implicit val context: ActorContex /* line 1c: vehicle is the same faction as player, is ownable, and either the owner is absent or the vehicle is destroyed */ /* line 2: vehicle is not mounted in anything or, if it is, its seats are empty */ if ( - (session.account.gm || - (player.avatar.vehicle.contains(objectGuid) && vehicle.OwnerGuid.contains(player.GUID)) || + ((avatar.vehicle.contains(objectGuid) && vehicle.OwnerGuid.contains(player.GUID)) || (player.Faction == vehicle.Faction && (vehicle.Definition.CanBeOwned.nonEmpty && (vehicle.OwnerGuid.isEmpty || continent.GUID(vehicle.OwnerGuid.get).isEmpty) || vehicle.Destroyed))) && @@ -324,7 +323,7 @@ class GeneralLogic(val ops: GeneralOperations, implicit val context: ActorContex } case Some(obj: Deployable) => - if (session.account.gm || obj.OwnerGuid.isEmpty || obj.OwnerGuid.contains(player.GUID) || obj.Destroyed) { + if (obj.OwnerGuid.isEmpty || obj.OwnerGuid.contains(player.GUID) || obj.Destroyed) { obj.Actor ! Deployable.Deconstruct() } else { log.warn(s"RequestDestroy: ${player.Name} must own the deployable in order to deconstruct it") diff --git a/src/main/scala/net/psforever/actors/session/support/ChatOperations.scala b/src/main/scala/net/psforever/actors/session/support/ChatOperations.scala index 02efbc7e4..2536eac69 100644 --- a/src/main/scala/net/psforever/actors/session/support/ChatOperations.scala +++ b/src/main/scala/net/psforever/actors/session/support/ChatOperations.scala @@ -7,7 +7,9 @@ import akka.actor.{ActorContext, typed} import net.psforever.actors.session.{AvatarActor, SessionActor} import net.psforever.actors.session.normal.{NormalMode => SessionNormalMode} import net.psforever.actors.session.spectator.{SpectatorMode => SessionSpectatorMode} +import net.psforever.actors.session.csr.{CustomerServiceRepresentativeMode => SessionCustomerServiceRepresentativeMode} import net.psforever.actors.zone.ZoneActor +import net.psforever.objects.avatar.ModePermissions import net.psforever.objects.sourcing.PlayerSource import net.psforever.objects.zones.ZoneInfo import net.psforever.packet.game.SetChatFilterMessage @@ -67,6 +69,8 @@ class ChatOperations( */ private val ignoredEmoteCooldown: mutable.LongMap[Long] = mutable.LongMap[Long]() + private[session] var SpectatorMode: PlayerMode = SessionSpectatorMode + import akka.actor.typed.scaladsl.adapter._ private val chatServiceAdapter: ActorRef[ChatService.MessageResponse] = context.self.toTyped[ChatService.MessageResponse] @@ -112,8 +116,8 @@ class ChatOperations( sendResponse(message.copy(contents = f"$speed%.3f")) } - def commandToggleSpectatorMode(session: Session, contents: String): Unit = { - val currentSpectatorActivation = session.player.spectator + def commandToggleSpectatorMode(contents: String): Unit = { + val currentSpectatorActivation = (if (avatar != null) avatar.permissions else ModePermissions()).canSpectate contents.toLowerCase() match { case "on" | "o" | "" if !currentSpectatorActivation => context.self ! SessionActor.SetMode(SessionSpectatorMode) @@ -1226,7 +1230,7 @@ class ChatOperations( params: Seq[String] ): Boolean = { val ourRank = BattleRank.withExperience(session.avatar.bep).value - if (!session.account.gm && + if (!avatar.permissions.canGM && (ourRank <= Config.app.game.promotion.broadcastBattleRank || ourRank > Config.app.game.promotion.resetBattleRank && ourRank < Config.app.game.promotion.maxBattleRank + 1)) { setBattleRank(session, params, AvatarActor.Progress) @@ -1253,6 +1257,18 @@ class ChatOperations( true } + def customCommandModerator(contents: String): Boolean = { + val currentCsrActivation = (if (avatar != null) avatar.permissions else ModePermissions()).canGM + contents.toLowerCase() match { + case "on" | "o" | "" if currentCsrActivation => + context.self ! SessionActor.SetMode(SessionCustomerServiceRepresentativeMode) + case "off" | "of" if currentCsrActivation => + context.self ! SessionActor.SetMode(SessionNormalMode) + case _ => () + } + true + } + def firstParam[T]( session: Session, buffer: Iterable[String], 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 528f34bf6..9d56fc5f3 100644 --- a/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala +++ b/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala @@ -3214,10 +3214,10 @@ class ZoningOperations( upstreamMessageCount = 0 if (tplayer.spectator) { if (!setAvatar) { - context.self ! SessionActor.SetMode(SpectatorMode) //should reload spectator status + context.self ! SessionActor.SetMode(sessionLogic.chat.SpectatorMode) //should reload spectator status } } else if ( - !account.gm && /* gm's are excluded */ + !avatar.permissions.canGM && /* 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 */ tavatar.scorecard.Lives.isEmpty && /* first life after login */