From 630c2809cb9afc6c92f8c936a3538553eb6e2ff4 Mon Sep 17 00:00:00 2001 From: Fate-JH Date: Mon, 24 Oct 2022 18:16:08 -0400 Subject: [PATCH] Shortcut to Grenade (#1010) * routine that finds, equips, and draws a grenade if the user has it; moved handling of ObjectHeldMessage from SessionActor to PlayerControl; placed a arm movement restriction condition * loading of, and adding and removing of shortcuts to/from both the database and the client hotbar * player-driven sanity tests to reload otherwise unavailable hotbar shortcuts; revamp to CreateShortcutMessage --- .../db/migration/V007__Shortcuts.sql | 9 + .../actor/service/AvatarServiceTest.scala | 4 +- .../actors/session/AvatarActor.scala | 291 ++++++++++++++---- .../psforever/actors/session/ChatActor.scala | 198 ++++++++++-- .../actors/session/SessionActor.scala | 103 +++---- .../net/psforever/login/WorldSession.scala | 257 +++++++++++++--- .../scala/net/psforever/objects/Player.scala | 37 ++- .../scala/net/psforever/objects/Players.scala | 6 +- .../net/psforever/objects/avatar/Avatar.scala | 1 + .../objects/avatar/PlayerControl.scala | 60 ++++ .../psforever/objects/avatar/Shortcut.scala | 76 +++++ .../packet/game/CreateShortcutMessage.scala | 167 +++++++--- .../net/psforever/persistence/Shortcut.scala | 11 + .../services/avatar/AvatarService.scala | 4 +- .../avatar/AvatarServiceMessage.scala | 2 +- .../avatar/AvatarServiceResponse.scala | 2 +- .../net/psforever/types/ImplantType.scala | 47 +-- .../game/CreateShortcutMessageTest.scala | 70 ++--- 18 files changed, 1032 insertions(+), 313 deletions(-) create mode 100644 server/src/main/resources/db/migration/V007__Shortcuts.sql create mode 100644 src/main/scala/net/psforever/objects/avatar/Shortcut.scala create mode 100644 src/main/scala/net/psforever/persistence/Shortcut.scala diff --git a/server/src/main/resources/db/migration/V007__Shortcuts.sql b/server/src/main/resources/db/migration/V007__Shortcuts.sql new file mode 100644 index 00000000..3ec11d39 --- /dev/null +++ b/server/src/main/resources/db/migration/V007__Shortcuts.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS "shortcut" ( + "avatar_id" INT NOT NULL REFERENCES avatar (id), + "slot" SMALLINT NOT NULL, + "purpose" SMALLINT NOT NULL, + "tile" VARCHAR(20) NOT NULL, + "effect1" VARCHAR(3), + "effect2" TEXT, + UNIQUE(avatar_id, slot) +); diff --git a/server/src/test/scala/actor/service/AvatarServiceTest.scala b/server/src/test/scala/actor/service/AvatarServiceTest.scala index 448d4a27..665c7ec7 100644 --- a/server/src/test/scala/actor/service/AvatarServiceTest.scala +++ b/server/src/test/scala/actor/service/AvatarServiceTest.scala @@ -237,8 +237,8 @@ class ObjectHeldTest extends ActorTest { ServiceManager.boot(system) val service = system.actorOf(Props(classOf[AvatarService], Zone.Nowhere), AvatarServiceTest.TestName) service ! Service.Join("test") - service ! AvatarServiceMessage("test", AvatarAction.ObjectHeld(PlanetSideGUID(10), 1)) - expectMsg(AvatarServiceResponse("/test/Avatar", PlanetSideGUID(10), AvatarResponse.ObjectHeld(1))) + service ! AvatarServiceMessage("test", AvatarAction.ObjectHeld(PlanetSideGUID(10), 1, 2)) + expectMsg(AvatarServiceResponse("/test/Avatar", PlanetSideGUID(10), AvatarResponse.ObjectHeld(1, 2))) } } } diff --git a/src/main/scala/net/psforever/actors/session/AvatarActor.scala b/src/main/scala/net/psforever/actors/session/AvatarActor.scala index f4a1ac1c..33883ed9 100644 --- a/src/main/scala/net/psforever/actors/session/AvatarActor.scala +++ b/src/main/scala/net/psforever/actors/session/AvatarActor.scala @@ -13,7 +13,7 @@ import scala.concurrent.{ExecutionContextExecutor, Future, Promise} import scala.util.{Failure, Success} import scala.concurrent.duration._ // -import net.psforever.objects.avatar.{Friend => AvatarFriend, Ignored => AvatarIgnored, _} +import net.psforever.objects.avatar.{Friend => AvatarFriend, Ignored => AvatarIgnored, Shortcut => AvatarShortcut, _} import net.psforever.objects.definition.converter.CharacterSelectConverter import net.psforever.objects.definition._ import net.psforever.objects.inventory.Container @@ -189,6 +189,10 @@ object AvatarActor { final case class MemberListRequest(action: MemberAction.Value, name: String) extends Command + final case class AddShortcut(slot: Int, shortcut: Shortcut) extends Command + + final case class RemoveShortcut(slot: Int) extends Command + final case class AvatarResponse(avatar: Avatar) final case class AvatarLoginResponse(avatar: Avatar) @@ -826,7 +830,7 @@ class AvatarActor( characters.headOption match { case None => val result = for { - id <- ctx.run( + _ <- ctx.run( query[persistence.Avatar] .insert( _.name -> lift(name), @@ -838,17 +842,6 @@ class AvatarActor( _.bep -> lift(Config.app.game.newAvatar.br.experience), _.cep -> lift(Config.app.game.newAvatar.cr.experience) ) - .returningGenerated(_.id) - ) - _ <- ctx.run( - liftQuery( - List( - persistence.Certification(Certification.MediumAssault.value, id), - persistence.Certification(Certification.ReinforcedExoSuit.value, id), - persistence.Certification(Certification.ATV.value, id), - persistence.Certification(Certification.Harasser.value, id) - ) - ).foreach(c => query[persistence.Certification].insert(c)) ) } yield () @@ -921,53 +914,46 @@ class AvatarActor( case LoginAvatar(replyTo) => import ctx._ val avatarId = avatar.id - val result = for { - //log this login - _ <- ctx.run(query[persistence.Avatar].filter(_.id == lift(avatarId)) - .update(_.lastLogin -> lift(LocalDateTime.now())) - ) - //log this choice of faction (no empire switching) - _ <- ctx.run(query[persistence.Account].filter(_.id == lift(account.id)) - .update(_.lastFactionId -> lift(avatar.faction.id)) - ) - //retrieve avatar data - loadouts <- initializeAllLoadouts() - implants <- ctx.run(query[persistence.Implant].filter(_.avatarId == lift(avatarId))) - certs <- ctx.run(query[persistence.Certification].filter(_.avatarId == lift(avatarId))) - locker <- loadLocker(avatarId) - friends <- loadFriendList(avatarId) - ignored <- loadIgnoredList(avatarId) - saved <- AvatarActor.loadSavedAvatarData(avatarId) - } yield (loadouts, implants, certs, locker, friends, ignored, saved) - result.onComplete { - case Success((_loadouts, implants, certs, locker, friendsList, ignoredList, saved)) => - avatarCopy( - avatar.copy( - loadouts = avatar.loadouts.copy(suit = _loadouts), - certifications = - certs.map(cert => Certification.withValue(cert.id)).toSet ++ Config.app.game.baseCertifications, - implants = implants.map(implant => Some(Implant(implant.toImplantDefinition))).padTo(3, None), - locker = locker, - people = MemberLists( - friend = friendsList, - ignored = ignoredList - ), - cooldowns = Cooldowns( - purchase = AvatarActor.buildCooldownsFromClob(saved.purchaseCooldowns, Avatar.purchaseCooldowns, log), - use = AvatarActor.buildCooldownsFromClob(saved.useCooldowns, Avatar.useCooldowns, log) - ) - ) - ) - // if we need to start stamina regeneration - tryRestoreStaminaForSession(stamina = 1) match { - case Some(_) => - defaultStaminaRegen(initialDelay = 0.5f seconds) - case _ => ; - } - replyTo ! AvatarLoginResponse(avatar) - case Failure(e) => - log.error(e)("db failure") - } + ctx.run( + query[persistence.Avatar] + .filter(_.id == lift(avatarId)) + .map { c => (c.created, c.lastLogin) } + ) + .onComplete { + case Success(value) if value.nonEmpty => + val (created, lastLogin) = value.head + if (created.equals(lastLogin)) { + //first login + //initialize default values that would be compromised during login if blank + val inits = for { + _ <- ctx.run( + liftQuery( + List( + persistence.Certification(Certification.StandardExoSuit.value, avatarId), + persistence.Certification(Certification.AgileExoSuit.value, avatarId), + persistence.Certification(Certification.ReinforcedExoSuit.value, avatarId), + persistence.Certification(Certification.StandardAssault.value, avatarId), + persistence.Certification(Certification.MediumAssault.value, avatarId), + persistence.Certification(Certification.ATV.value, avatarId), + persistence.Certification(Certification.Harasser.value, avatarId) + ) + ).foreach(c => query[persistence.Certification].insert(c)) + ) + _ <- ctx.run( + liftQuery( + List(persistence.Shortcut(avatarId, 0, 0, "medkit")) + ).foreach(c => query[persistence.Shortcut].insert(c)) + ) + } yield true + inits.onComplete { + case Success(_) => performAvatarLogin(avatarId, account.id, replyTo) + case Failure(e) => log.error(e)("db failure") + } + } else { + performAvatarLogin(avatarId, account.id, replyTo) + } + case Failure(e) => log.error(e)("db failure") + } Behaviors.same case ReplaceAvatar(newAvatar) => @@ -1353,7 +1339,7 @@ class AvatarActor( updatePurchaseTimer( name, cooldown.toSeconds, - DefinitionUtil.fromString(name).isInstanceOf[VehicleDefinition] + item.isInstanceOf[VehicleDefinition] ) case _ => ; } @@ -1617,6 +1603,91 @@ class AvatarActor( case MemberListRequest(action, name) => memberListAction(action, name) Behaviors.same + + case AddShortcut(slot, shortcut) => + import ctx._ + if (slot > -1) { + val targetShortcut = avatar.shortcuts.lift(slot).flatten + //short-circuit if the shortcut already exists at the given location + val isMacroShortcut = shortcut.isInstanceOf[Shortcut.Macro] + val isDifferentShortcut = !(targetShortcut match { + case Some(target) => AvatarShortcut.equals(shortcut, target) + case _ => false + }) + if (isDifferentShortcut) { + if (!isMacroShortcut && avatar.shortcuts.flatten.exists { + a => AvatarShortcut.equals(shortcut, a) + }) { + //duplicate implant or medkit found + if (shortcut.isInstanceOf[Shortcut.Implant]) { + //duplicate implant + targetShortcut match { + case Some(existingShortcut) => + //redraw redundant shortcut slot with existing shortcut + sessionActor ! SessionActor.SendResponse( + CreateShortcutMessage(session.get.player.GUID, slot + 1, Some(AvatarShortcut.convert(existingShortcut))) + ) + case _ => + //blank shortcut slot + sessionActor ! SessionActor.SendResponse(CreateShortcutMessage(session.get.player.GUID, slot + 1, None)) + } + } + } else { + //macro, or implant or medkit + val (optEffect1, optEffect2, optShortcut) = shortcut match { + case Shortcut.Macro(acro, msg) => + ( + acro, + msg, + Some(AvatarShortcut(shortcut.code, shortcut.tile, acro, msg)) + ) + case _ => + (null, null, Some(AvatarShortcut(shortcut.code, shortcut.tile))) + } + targetShortcut match { + case Some(_) => + ctx.run( + query[persistence.Shortcut] + .filter(_.avatarId == lift(avatar.id.toLong)) + .filter(_.slot == lift(slot)) + .update( + _.purpose -> lift(shortcut.code), + _.tile -> lift(shortcut.tile), + _.effect1 -> Option(lift(optEffect1)), + _.effect2 -> Option(lift(optEffect2)) + ) + ) + case None => + ctx.run( + query[persistence.Shortcut].insert( + _.avatarId -> lift(avatar.id.toLong), + _.slot -> lift(slot), + _.purpose -> lift(shortcut.code), + _.tile -> lift(shortcut.tile), + _.effect1 -> Option(lift(optEffect1)), + _.effect2 -> Option(lift(optEffect2)) + ) + ) + } + avatar.shortcuts.update(slot, optShortcut) + } + } + } + Behaviors.same + + case RemoveShortcut(slot) => + import ctx._ + avatar.shortcuts.lift(slot).flatten match { + case None => ; + case Some(_) => + ctx.run(query[persistence.Shortcut] + .filter(_.avatarId == lift(avatar.id.toLong)) + .filter(_.slot == lift(slot)) + .delete + ) + avatar.shortcuts.update(slot, None) + } + Behaviors.same } .receiveSignal { case (_, PostStop) => @@ -1636,6 +1707,78 @@ class AvatarActor( Future.failed(ex).asInstanceOf[Future[Loadout]] } + def performAvatarLogin(avatarId: Long, accountId: Long, replyTo: ActorRef[AvatarLoginResponse]): Unit = { + import ctx._ + + val result = for { + //log this login + _ <- ctx.run(query[persistence.Avatar].filter(_.id == lift(avatarId)) + .update(_.lastLogin -> lift(LocalDateTime.now())) + ) + //log this choice of faction (no empire switching) + _ <- ctx.run(query[persistence.Account].filter(_.id == lift(accountId)) + .update(_.lastFactionId -> lift(avatar.faction.id)) + ) + //retrieve avatar data + loadouts <- initializeAllLoadouts() + implants <- ctx.run(query[persistence.Implant].filter(_.avatarId == lift(avatarId))) + certs <- ctx.run(query[persistence.Certification].filter(_.avatarId == lift(avatarId))) + locker <- loadLocker(avatarId) + friends <- loadFriendList(avatarId) + ignored <- loadIgnoredList(avatarId) + shortcuts <- loadShortcuts(avatarId) + saved <- AvatarActor.loadSavedAvatarData(avatarId) + } yield (loadouts, implants, certs, locker, friends, ignored, shortcuts, saved) + result.onComplete { + case Success((_loadouts, implants, certs, locker, friendsList, ignoredList, shortcutList, saved)) => + //shortcuts must have a hotbar option for each implant +// val implantShortcuts = shortcutList.filter { +// case Some(e) => e.purpose == 0 +// case None => false +// } +// implants.filterNot { implant => +// implantShortcuts.exists { +// case Some(a) => a.tile.equals(implant.name) +// case None => false +// } +// }.foreach { c => +// shortcutList.indexWhere { _.isEmpty } match { +// case -1 => ; +// case index => +// shortcutList.update(index, Some(AvatarShortcut(2, c.name))) +// } +// } + // + avatarCopy( + avatar.copy( + loadouts = avatar.loadouts.copy(suit = _loadouts), + certifications = + certs.map(cert => Certification.withValue(cert.id)).toSet ++ Config.app.game.baseCertifications, + implants = implants.map(implant => Some(Implant(implant.toImplantDefinition))).padTo(3, None), + shortcuts = shortcutList, + locker = locker, + people = MemberLists( + friend = friendsList, + ignored = ignoredList + ), + cooldowns = Cooldowns( + purchase = AvatarActor.buildCooldownsFromClob(saved.purchaseCooldowns, Avatar.purchaseCooldowns, log), + use = AvatarActor.buildCooldownsFromClob(saved.useCooldowns, Avatar.useCooldowns, log) + ) + ) + ) + // if we need to start stamina regeneration + tryRestoreStaminaForSession(stamina = 1) match { + case Some(_) => + defaultStaminaRegen(initialDelay = 0.5f seconds) + case _ => ; + } + replyTo ! AvatarLoginResponse(avatar) + case Failure(e) => + log.error(e)("db failure") + } + } + /** * na * @see `avatarCopy(Avatar)` @@ -1781,8 +1924,6 @@ class AvatarActor( CreateShortcutMessage( session.get.player.GUID, slot + 2, - 0, - addShortcut = true, Some(implant.definition.implantType.shortcut) ) ) @@ -2303,6 +2444,30 @@ class AvatarActor( out.future } + def loadShortcuts(avatarId: Long): Future[Array[Option[AvatarShortcut]]] = { + import ctx._ + val out: Promise[Array[Option[AvatarShortcut]]] = Promise() + + val queryResult = ctx.run( + query[persistence.Shortcut].filter { _.avatarId == lift(avatarId) } + .map { shortcut => (shortcut.slot, shortcut.purpose, shortcut.tile, shortcut.effect1, shortcut.effect2) } + ) + val output = Array.fill[Option[AvatarShortcut]](64)(None) + queryResult.onComplete { + case Success(list) => + list.foreach { case (slot, purpose, tile, effect1, effect2) => + output.update(slot, Some(AvatarShortcut(purpose, tile, effect1.getOrElse(""), effect2.getOrElse("")))) + } + out.completeWith(Future(output)) + case Failure(e) => + //something went wrong, but we can recover + log.warn(e)("db failure") + //output.update(0, Some(AvatarShortcut(0, "medkit"))) + out.completeWith(Future(output)) + } + out.future + } + def startIfStoppedStaminaRegen(initialDelay: FiniteDuration): Unit = { if (staminaRegenTimer.isCancelled) { defaultStaminaRegen(initialDelay) diff --git a/src/main/scala/net/psforever/actors/session/ChatActor.scala b/src/main/scala/net/psforever/actors/session/ChatActor.scala index e83a2c22..e031b452 100644 --- a/src/main/scala/net/psforever/actors/session/ChatActor.scala +++ b/src/main/scala/net/psforever/actors/session/ChatActor.scala @@ -1,31 +1,37 @@ package net.psforever.actors.session import akka.actor.Cancellable -import akka.actor.typed.receptionist.Receptionist import akka.actor.typed.{ActorRef, Behavior, PostStop, SupervisorStrategy} +import akka.actor.typed.receptionist.Receptionist import akka.actor.typed.scaladsl.{ActorContext, Behaviors, StashBuffer} +import akka.actor.typed.scaladsl.adapter._ +import net.psforever.objects.avatar.{Shortcut => AvatarShortcut} +import net.psforever.objects.definition.ImplantDefinition +import net.psforever.packet.game.{CreateShortcutMessage, Shortcut} +import net.psforever.packet.game.objectcreate.DrawnSlot +import net.psforever.types.ImplantType + +import scala.collection.mutable +import scala.concurrent.ExecutionContextExecutor +import scala.concurrent.duration._ +// import net.psforever.actors.zone.BuildingActor +import net.psforever.login.WorldSession +import net.psforever.objects.{Default, Player, Session} import net.psforever.objects.avatar.{BattleRank, Certification, CommandRank, Cosmetic} import net.psforever.objects.serverobject.pad.{VehicleSpawnControl, VehicleSpawnPad} -import net.psforever.objects.{Default, Player, Session} import net.psforever.objects.serverobject.resourcesilo.ResourceSilo import net.psforever.objects.serverobject.structures.{Amenity, Building} import net.psforever.objects.serverobject.turret.{FacilityTurret, TurretUpgrade, WeaponTurrets} import net.psforever.objects.zones.Zoning import net.psforever.packet.game.{ChatMsg, DeadState, RequestDestroyMessage, ZonePopulationUpdateMessage} +import net.psforever.services.{CavernRotationService, InterstellarClusterService} +import net.psforever.services.chat.ChatService +import net.psforever.services.chat.ChatService.ChatChannel +import net.psforever.types.ChatMessageType.UNK_229 import net.psforever.types.{ChatMessageType, PlanetSideEmpire, PlanetSideGUID, Vector3} import net.psforever.util.{Config, PointOfInterest} import net.psforever.zones.Zones -import net.psforever.services.chat.ChatService -import net.psforever.services.chat.ChatService.ChatChannel - -import scala.concurrent.ExecutionContextExecutor -import scala.concurrent.duration._ -import akka.actor.typed.scaladsl.adapter._ -import net.psforever.services.{CavernRotationService, InterstellarClusterService} -import net.psforever.types.ChatMessageType.UNK_229 - -import scala.collection.mutable object ChatActor { def apply( @@ -430,7 +436,7 @@ class ChatActor( case (_, _, "!loc") => val continent = session.zone - val player = session.player + val player = session.player val loc = s"zone=${continent.id} pos=${player.Position.x},${player.Position.y},${player.Position.z}; ori=${player.Orientation.x},${player.Orientation.y},${player.Orientation.z}" log.info(loc) @@ -487,8 +493,8 @@ class ChatActor( val buffer = content.toLowerCase.split("\\s+") val (facility, customNtuValue) = (buffer.lift(1), buffer.lift(2)) match { case (Some(x), Some(y)) if y.toIntOption.nonEmpty => (Some(x), Some(y.toInt)) - case (Some(x), None) if x.toIntOption.nonEmpty => (None, Some(x.toInt)) - case _ => (None, None) + case (Some(x), None) if x.toIntOption.nonEmpty => (None, Some(x.toInt)) + case _ => (None, None) } val silos = (facility match { case Some(cur) if cur.toLowerCase().startsWith("curr") => @@ -501,12 +507,17 @@ class ChatActor( case Some(all) if all.toLowerCase.startsWith("all") => session.zone.Buildings.values case Some(x) => - session.zone.Buildings.values.find { _.Name.equalsIgnoreCase(x) }.toList + session.zone.Buildings.values.find { + _.Name.equalsIgnoreCase(x) + }.toList case _ => session.zone.Buildings.values }) - .flatMap { building => building.Amenities.filter { _.isInstanceOf[ResourceSilo] } } - ChatActor.setBaseResources(sessionActor, customNtuValue, silos, debugContent=s"$facility") + .flatMap { building => building.Amenities.filter { + _.isInstanceOf[ResourceSilo] + } + } + ChatActor.setBaseResources(sessionActor, customNtuValue, silos, debugContent = s"$facility") case (_, _, content) if content.startsWith("!zonerotate") && gmCommandAllowed => val buffer = contents.toLowerCase.split("\\s+") @@ -523,8 +534,53 @@ class ChatActor( tplayer.Revive tplayer.Actor ! Player.Die() - case _ => - // unknown ! commands are ignored + case (_, _, content) if content.startsWith("!grenade") => + WorldSession.QuickSwapToAGrenade(session.player, DrawnSlot.Pistol1.id, log) + + case (_, _, content) if content.startsWith("!macro") => + val avatar = session.avatar + val args = contents.split(" ").filter(_ != "") + (args.lift(1), args.lift(2)) match { + case (Some(cmd), other) => + cmd.toLowerCase() match { + case "medkit" => + medkitSanityTest(session.player.GUID, avatar.shortcuts) + + case "implants" => + //implant shortcut sanity test + implantSanityTest( + session.player.GUID, + avatar.implants.collect { + case Some(implant) if implant.definition.implantType != ImplantType.None => implant.definition + }, + avatar.shortcuts + ) + + case name + if ImplantType.values.exists { a => a.shortcut.tile.equals(name) } => + avatar.implants.find { + case Some(implant) => implant.definition.Name.equalsIgnoreCase(name) + case None => false + } match { + case Some(Some(implant)) => + //specific implant shortcut sanity test + implantSanityTest(session.player.GUID, Seq(implant.definition), avatar.shortcuts) + case _ if other.nonEmpty => + //add macro? + macroSanityTest(session.player.GUID, name, args.drop(2).mkString(" "), avatar.shortcuts) + case _ => ; + } + + case name + if name.nonEmpty && other.nonEmpty => + //add macro + macroSanityTest(session.player.GUID, name, args.drop(2).mkString(" "), avatar.shortcuts) + + case _ => ; + } + case _ => ; + // unknown ! commands are ignored + } } case (CMT_CAPTUREBASE, _, contents) if gmCommandAllowed => @@ -1168,7 +1224,107 @@ class ChatActor( case _ => Behaviors.same } - } + /** + * Create a medkit shortcut if there is no medkit shortcut on the hotbar. + * Bounce the packet to the client and the client will bounce it back to the server to continue the setup, + * or cancel / invalidate the shortcut creation. + * @see `Array::indexWhere` + * @see `CreateShortcutMessage` + * @see `net.psforever.objects.avatar.Shortcut` + * @see `net.psforever.packet.game.Shortcut.Medkit` + * @see `SessionActor.SendResponse` + * @param guid current player unique identifier for the target client + * @param shortcuts list of all existing shortcuts, used for early validation + */ + def medkitSanityTest( + guid: PlanetSideGUID, + shortcuts: Array[Option[AvatarShortcut]] + ): Unit = { + if (!shortcuts.exists { + case Some(a) => a.purpose == 0 + case None => false + }) { + shortcuts.indexWhere(_.isEmpty) match { + case -1 => ; + case index => + //new shortcut + sessionActor ! SessionActor.SendResponse(CreateShortcutMessage( + guid, + index + 1, + Some(Shortcut.Medkit()) + )) + } + } + } + + /** + * Create all implant macro shortcuts for all implants whose shortcuts have been removed from the hotbar. + * Bounce the packet to the client and the client will bounce it back to the server to continue the setup, + * or cancel / invalidate the shortcut creation. + * @see `CreateShortcutMessage` + * @see `ImplantDefinition` + * @see `net.psforever.objects.avatar.Shortcut` + * @see `SessionActor.SendResponse` + * @param guid current player unique identifier for the target client + * @param haveImplants list of implants the player possesses + * @param shortcuts list of all existing shortcuts, used for early validation + */ + def implantSanityTest( + guid: PlanetSideGUID, + haveImplants: Iterable[ImplantDefinition], + shortcuts: Array[Option[AvatarShortcut]] + ): Unit = { + val haveImplantShortcuts = shortcuts.collect { + case Some(shortcut) if shortcut.purpose == 2 => shortcut.tile + } + var start: Int = 0 + haveImplants.filterNot { imp => haveImplantShortcuts.contains(imp.Name) } + .foreach { implant => + shortcuts.indexWhere(_.isEmpty, start) match { + case -1 => ; + case index => ; + //new shortcut + start = index + 1 + sessionActor ! SessionActor.SendResponse(CreateShortcutMessage( + guid, + start, + Some(implant.implantType.shortcut) + )) + } + } + } + + /** + * Create a text chat macro shortcut if it doesn't already exist. + * Bounce the packet to the client and the client will bounce it back to the server to continue the setup, + * or cancel / invalidate the shortcut creation. + * @see `Array::indexWhere` + * @see `CreateShortcutMessage` + * @see `net.psforever.objects.avatar.Shortcut` + * @see `net.psforever.packet.game.Shortcut.Macro` + * @see `SessionActor.SendResponse` + * @param guid current player unique identifier for the target client + * @param acronym three letters emblazoned on the shortcut icon + * @param msg the message published to text chat + * @param shortcuts a list of all existing shortcuts, used for early validation + */ + def macroSanityTest( + guid: PlanetSideGUID, + acronym: String, + msg: String, + shortcuts: Array[Option[AvatarShortcut]] + ): Unit = { + shortcuts.indexWhere(_.isEmpty) match { + case -1 => ; + case index => ; + //new shortcut + sessionActor ! SessionActor.SendResponse(CreateShortcutMessage( + guid, + index + 1, + Some(Shortcut.Macro(acronym, msg)) + )) + } + } } diff --git a/src/main/scala/net/psforever/actors/session/SessionActor.scala b/src/main/scala/net/psforever/actors/session/SessionActor.scala index f8fa791f..a2b66d94 100644 --- a/src/main/scala/net/psforever/actors/session/SessionActor.scala +++ b/src/main/scala/net/psforever/actors/session/SessionActor.scala @@ -9,7 +9,7 @@ import net.psforever.actors.net.MiddlewareActor import net.psforever.actors.zone.ZoneActor import net.psforever.login.WorldSession._ import net.psforever.objects._ -import net.psforever.objects.avatar._ +import net.psforever.objects.avatar.{Shortcut => AvatarShortcut, _} import net.psforever.objects.ballistics._ import net.psforever.objects.ce._ import net.psforever.objects.definition._ @@ -1649,6 +1649,10 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con player.Armor = results.armor player.ExoSuit = ExoSuitType(results.exosuitNum) AvatarActor.buildContainedEquipmentFromClob(player, results.loadout, log) + if (player.ExoSuit == ExoSuitType.MAX) { + player.DrawnSlot = 0 + player.ResistArmMotion(PlayerControl.maxRestriction) + } } else { player.ExoSuit = ExoSuitType.Standard DefinitionUtil.applyDefaultLoadout(player) @@ -2311,9 +2315,21 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con sendResponse(ObjectDeleteMessage(item_guid, unk)) } - case AvatarResponse.ObjectHeld(slot) => - if (tplayer_guid != guid) { - sendResponse(ObjectHeldMessage(guid, slot, false)) + case AvatarResponse.ObjectHeld(slot, previousSLot) => + if (tplayer_guid == guid) { + if (slot > -1) { + sendResponse(ObjectHeldMessage(guid, slot, unk1=true)) + //Stop using proximity terminals if player unholsters a weapon + if (player.VisibleSlots.contains(slot)) { + continent.GUID(usingMedicalTerminal) match { + case Some(term: Terminal with ProximityUnit) => + StopUsingProximityUnit(term) + case _ => ; + } + } + } + } else { + sendResponse(ObjectHeldMessage(guid, previousSLot, unk1=false)) } case AvatarResponse.OxygenState(player, vehicle) => @@ -2565,7 +2581,7 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con sendResponse(PlanetsideAttributeMessage(target, 4, armor)) if (tplayer_guid == target) { //happening to this player - sendResponse(ObjectHeldMessage(target, Player.HandsDownSlot, false)) + sendResponse(ObjectHeldMessage(target, Player.HandsDownSlot, true)) //cleanup (old_holsters ++ old_inventory).foreach { case (obj, objGuid) => @@ -3382,10 +3398,10 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con CancelAllProximityUnits() if (player.VisibleSlots.contains(player.DrawnSlot)) { player.DrawnSlot = Player.HandsDownSlot - sendResponse(ObjectHeldMessage(player.GUID, Player.HandsDownSlot, true)) + sendResponse(ObjectHeldMessage(player.GUID, Player.HandsDownSlot, unk1=true)) continent.AvatarEvents ! AvatarServiceMessage( continent.id, - AvatarAction.ObjectHeld(player.GUID, player.LastDrawnSlot) + AvatarAction.SendResponse(player.GUID, ObjectHeldMessage(player.GUID, player.LastDrawnSlot, unk1=false)) ) } sendResponse(PlanetsideAttributeMessage(vehicle_guid, 22, 1L)) //mount points off @@ -3608,15 +3624,23 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con sendResponse(PlanetsideAttributeMessage(PlanetSideGUID(0), 82, 0)) //TODO if Medkit does not have shortcut, add to a free slot or write over slot 64 - sendResponse(CreateShortcutMessage(guid, 1, 0, true, Shortcut.Medkit)) + avatar.shortcuts + .zipWithIndex + .collect { case (Some(shortcut), index) => + sendResponse(CreateShortcutMessage( + guid, + index + 1, + Some(AvatarShortcut.convert(shortcut)) + )) + } sendResponse(ChangeShortcutBankMessage(guid, 0)) //Favorites lists avatarActor ! AvatarActor.InitialRefreshLoadouts() sendResponse( - SetChatFilterMessage(ChatChannel.Platoon, false, ChatChannel.values.toList) + SetChatFilterMessage(ChatChannel.Platoon, origin=false, ChatChannel.values.toList) ) //TODO will not always be "on" like this - sendResponse(AvatarDeadStateMessage(DeadState.Alive, 0, 0, tplayer.Position, player.Faction, true)) + sendResponse(AvatarDeadStateMessage(DeadState.Alive, 0, 0, tplayer.Position, player.Faction, unk5=true)) //looking for squad (members) if (tplayer.avatar.lookingForSquad || lfsm) { sendResponse(PlanetsideAttributeMessage(guid, 53, 1)) @@ -5033,54 +5057,8 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con log.warn(s"ReloadMessage: either can not find $item_guid or the object found was not a Tool") } - case msg @ ObjectHeldMessage(avatar_guid, held_holsters, unk1) => - val before = player.DrawnSlot - if (before != held_holsters) { - if (player.ExoSuit == ExoSuitType.MAX && held_holsters != 0) { - log.warn(s"ObjectHeld: ${player.Name} is denied changing hands to $held_holsters as a MAX") - player.DrawnSlot = 0 - sendResponse(ObjectHeldMessage(avatar_guid, 0, true)) - } else if ((player.DrawnSlot = held_holsters) != before) { - continent.AvatarEvents ! AvatarServiceMessage( - player.Continent, - AvatarAction.ObjectHeld(player.GUID, player.LastDrawnSlot) - ) - // Ignore non-equipment holsters - //todo: check current suit holster slots? - val isHolsters = held_holsters >= 0 && held_holsters < 5 - val equipment = player.Slot(held_holsters).Equipment.orElse { player.Slot(before).Equipment } - if (isHolsters) { - equipment match { - case Some(unholsteredItem: Equipment) => - log.info(s"${player.Name} has drawn a $unholsteredItem from its holster") - if (unholsteredItem.Definition == GlobalDefinitions.remote_electronics_kit) { - //rek beam/icon colour must match the player's correct hack level - continent.AvatarEvents ! AvatarServiceMessage( - player.Continent, - AvatarAction.PlanetsideAttribute(unholsteredItem.GUID, 116, player.avatar.hackingSkillLevel()) - ) - } - case None => ; - } - } else { - equipment match { - case Some(holsteredEquipment) => - log.info(s"${player.Name} has put ${player.Sex.possessive} ${holsteredEquipment.Definition.Name} down") - case None => - log.info(s"${player.Name} lowers ${player.Sex.possessive} hand") - } - } - - // Stop using proximity terminals if player unholsters a weapon (which should re-trigger the proximity effect and re-holster the weapon) - if (player.VisibleSlots.contains(held_holsters)) { - continent.GUID(usingMedicalTerminal) match { - case Some(term: Terminal with ProximityUnit) => - StopUsingProximityUnit(term) - case _ => ; - } - } - } - } + case ObjectHeldMessage(_, held_holsters, _) => + player.Actor ! PlayerControl.ObjectHeld(held_holsters) case msg @ AvatarJumpMessage(state) => avatarActor ! AvatarActor.ConsumeStamina(10) @@ -6490,7 +6468,14 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con log.info(lament) log.debug(s"Battleplan: $lament - $msg") - case msg @ CreateShortcutMessage(player_guid, slot, unk, add, shortcut) => ; + case CreateShortcutMessage(_, slot, Some(shortcut)) => + avatarActor ! AvatarActor.AddShortcut(slot - 1, shortcut) + + case CreateShortcutMessage(_, slot, None) => + avatarActor ! AvatarActor.RemoveShortcut(slot - 1) + + case ChangeShortcutBankMessage(_, bank) => + log.info(s"${player.Name} referencing hotbar bank $bank") case FriendsRequest(action, name) => avatarActor ! AvatarActor.MemberListRequest(action, name) diff --git a/src/main/scala/net/psforever/login/WorldSession.scala b/src/main/scala/net/psforever/login/WorldSession.scala index 901bc9a7..6c768d74 100644 --- a/src/main/scala/net/psforever/login/WorldSession.scala +++ b/src/main/scala/net/psforever/login/WorldSession.scala @@ -3,6 +3,7 @@ package net.psforever.login import akka.actor.ActorRef import akka.pattern.{AskTimeoutException, ask} import akka.util.Timeout +import net.psforever.objects._ import net.psforever.objects.equipment.{Ammo, Equipment, EquipmentSize} import net.psforever.objects.guid._ import net.psforever.objects.inventory.{Container, InventoryItem} @@ -10,9 +11,7 @@ import net.psforever.objects.locker.LockerContainer import net.psforever.objects.serverobject.PlanetSideServerObject import net.psforever.objects.serverobject.containable.Containable import net.psforever.objects.zones.Zone -import net.psforever.objects._ -import net.psforever.packet.game.ObjectHeldMessage -import net.psforever.types.{PlanetSideGUID, TransactionType, Vector3} +import net.psforever.types.{ExoSuitType, PlanetSideGUID, TransactionType, Vector3} import net.psforever.services.Service import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage} @@ -35,7 +34,7 @@ object WorldSession { private implicit val timeout = new Timeout(5000 milliseconds) /** - * Use this for placing equipment that has yet to be registered into a container, + * Use this for placing equipment that has already been registered into a container, * such as in support of changing ammunition types in `Tool` objects (weapons). * If the object can not be placed into the container, it will be dropped onto the ground. * It will also be dropped if it takes too long to be placed. @@ -299,41 +298,7 @@ object WorldSession { if (player.VisibleSlots.contains(slot)) { val localZone = player.Zone TaskBundle( - new StraightforwardTask() { - private val localPlayer = player - private val localGUID = player.GUID - private val localItem = item - private val localSlot = slot - - def action(): Future[Any] = { - ask(localPlayer.Actor, Containable.PutItemInSlotOnly(localItem, localSlot)) - .onComplete { - case Failure(_) | Success(_: Containable.CanNotPutItemInSlot) => - TaskWorkflow.execute(GUIDTask.unregisterEquipment(localZone.GUID, localItem)) - case _ => - if (localPlayer.DrawnSlot != Player.HandsDownSlot) { - localPlayer.DrawnSlot = Player.HandsDownSlot - localZone.AvatarEvents ! AvatarServiceMessage( - localPlayer.Name, - AvatarAction.SendResponse( - Service.defaultPlayerGUID, - ObjectHeldMessage(localGUID, Player.HandsDownSlot, false) - ) - ) - localZone.AvatarEvents ! AvatarServiceMessage( - localZone.id, - AvatarAction.ObjectHeld(localGUID, localPlayer.LastDrawnSlot) - ) - } - localPlayer.DrawnSlot = localSlot - localZone.AvatarEvents ! AvatarServiceMessage( - localZone.id, - AvatarAction.SendResponse(Service.defaultPlayerGUID, ObjectHeldMessage(localGUID, localSlot, false)) - ) - } - Future(this) - } - }, + TaskToHoldEquipmentUp(player)(item, slot), GUIDTask.registerEquipment(localZone.GUID, item) ) } else { @@ -342,6 +307,32 @@ object WorldSession { } } + def TaskToHoldEquipmentUp(player: Player)(item: Equipment, slot: Int): Task = { + new StraightforwardTask() { + private val localPlayer = player + private val localGUID = player.GUID + private val localItem = item + private val localSlot = slot + private val localZone = player.Zone + + def action(): Future[Any] = { + ask(localPlayer.Actor, Containable.PutItemInSlotOnly(localItem, localSlot)) + .onComplete { + case Failure(_) | Success(_: Containable.CanNotPutItemInSlot) => + TaskWorkflow.execute(GUIDTask.unregisterEquipment(localZone.GUID, localItem)) + case _ => + forcedTolowerRaisedArm(localPlayer, localPlayer.GUID, localZone) + localPlayer.DrawnSlot = localSlot + localZone.AvatarEvents ! AvatarServiceMessage( + localZone.id, + AvatarAction.ObjectHeld(localGUID, localSlot, localSlot) + ) + } + Future(this) + } + } + } + /** * Get an item from the ground and put it into the given container. * The zone in which the item is found is expected to be the same in which the container object is located. @@ -726,6 +717,194 @@ object WorldSession { } } + /** + * Quickly draw a grenade from anywhere on the player's person and place it into a certain hand + * at the ready to be used as a weapon. + * Soldiers in mechanized assault exo-suits can not perform this action.
+ *
+ * This is not vanilla behavior.
+ *
+ * Search for a grenade of either fragmentation- or plasma-type in the hands (holsters) or backpack (inventory) + * and bring it to hand and draw that grenade as a weapon as quickly as possible. + * If the player has a weapon already drawn, remove it from his active hand quickly. + * It may be placed back into the slot once the hand is / will be occupied by a grenade. + * For anything in the first sidearm weapon slot, where the grenade will be placed, + * either find room in the backpack for it or drop it on the ground. + * If the player's already-drawn hand is the same as the one that will hold the grenade (first sidearm holster), + * treat it like the sidearm occupier rather than the already-drawn weapon - + * the old weapon goes into the backpack or onto the ground. + * @see `AvatarAction.ObjectHeld` + * @see `AvatarServiceMessage` + * @see `Containable.RemoveItemFromSlot` + * @see `countRestrictAttempts` + * @see `forcedTolowerRaisedArm` + * @see `GlobalDefinitions.isGrenade` + * @see `InventoryItem` + * @see `Player.DrawnSlot` + * @see `Player.HandsDownSlot` + * @see `Player.Holsters` + * @see `Player.ResistArmMotion` + * @see `Player.Slot` + * @see `PutEquipmentInInventoryOrDrop` + * @see `PutEquipmentInInventorySlot` + * @see `TaskBundle` + * @see `TaskToHoldEquipmentUp` + * @see `TaskWorkflow.execute` + * @param tplayer player who wants to draw a grenade + * @param equipSlot slot being used as the final destination for any discovered grenade + * @param log reference to the messaging protocol + * @return if there was a discovered grenade + */ + def QuickSwapToAGrenade( + tplayer: Player, + equipSlot: Int, + log: org.log4s.Logger): Boolean = { + if (tplayer.ExoSuit != ExoSuitType.MAX) { + val previouslyDrawnSlot = tplayer.DrawnSlot + val optGrenadeInSlot = { + tplayer.Holsters().zipWithIndex.find { case (slot, _) => + slot.Equipment match { + case Some(equipment) => + val definition = equipment.Definition + val name = definition.Name + GlobalDefinitions.isGrenade(definition) && (name.contains("frag") || name.contains("plasma")) + case _ => + false + } + } match { + case Some((_, slotNum)) if slotNum == previouslyDrawnSlot => + //grenade already in hand; do nothing + None + case Some((grenadeSlot, slotNum)) => + //grenade is holstered in some other slot; just extend it (or swap hands) + val guid = tplayer.GUID + val zone = tplayer.Zone + val grenade = grenadeSlot.Equipment.get + val drawnSlotItem = tplayer.Slot(tplayer.DrawnSlot).Equipment + if (forcedTolowerRaisedArm(tplayer, guid, zone)) { + log.info(s"${tplayer.Name} has dropped ${tplayer.Sex.possessive} ${drawnSlotItem.get.Definition.Name}") + } + //put up hand with grenade in it + tplayer.DrawnSlot = slotNum + zone.AvatarEvents ! AvatarServiceMessage( + zone.id, + AvatarAction.ObjectHeld(guid, slotNum, slotNum) + ) + log.info(s"${tplayer.Name} has quickly drawn a ${grenade.Definition.Name}") + None + case None => + //check inventory for a grenade + tplayer.Inventory.Items.find { case InventoryItem(equipment, _) => + val definition = equipment.Definition + val name = definition.Name + GlobalDefinitions.isGrenade(definition) && (name.contains("frag") || name.contains("plasma")) + } match { + case Some(InventoryItem(equipment, slotNum)) =>Some(equipment.asInstanceOf[Tool], slotNum) + case None => None + } + } + } + optGrenadeInSlot match { + case Some((grenade, slotNum)) => + tplayer.ResistArmMotion(countRestrictAttempts(count=1)) + val itemInPreviouslyDrawnSlotToDrop = if (equipSlot != previouslyDrawnSlot) { + forcedTolowerRaisedArm(tplayer, tplayer.GUID, tplayer.Zone) + tplayer.Slot(previouslyDrawnSlot).Equipment match { + case out @ Some(_) => out + case _ => None + } + } else { + None + } + val itemPreviouslyInPistolSlot = tplayer.Slot(equipSlot).Equipment + val result = for { + //remove grenade from inventory + a <- ask(tplayer.Actor, Containable.RemoveItemFromSlot(slotNum)) + //remove equipment from pistol slot, where grenade will go + b <- itemPreviouslyInPistolSlot match { + case Some(_) => ask(tplayer.Actor, Containable.RemoveItemFromSlot(equipSlot)) + case _ => Future(true) + } + //remove held equipment (if any) + c <- itemInPreviouslyDrawnSlotToDrop match { + case Some(_) => ask(tplayer.Actor, Containable.RemoveItemFromSlot(previouslyDrawnSlot)) + case _ => Future(false) + } + } yield (a, b, c) + result.onComplete { + case Success((_, _, _)) => + //put equipment in hand and hold grenade up + TaskWorkflow.execute(TaskBundle(TaskToHoldEquipmentUp(tplayer)(grenade, equipSlot))) + //what to do with the equipment that was removed for the grenade + itemPreviouslyInPistolSlot match { + case Some(e) => + log.info(s"${tplayer.Name} has dropped ${tplayer.Sex.possessive} ${e.Definition.Name}") + PutEquipmentInInventoryOrDrop(tplayer)(e) + case _ => ; + } + //restore previously-held-up equipment + itemInPreviouslyDrawnSlotToDrop match { + case Some(e) => PutEquipmentInInventorySlot(tplayer)(e, previouslyDrawnSlot) + case _ => ; + } + log.info(s"${tplayer.Name} has quickly drawn a ${grenade.Definition.Name}") + case _ => ; + } + case None => ; + } + optGrenadeInSlot.nonEmpty + } else { + false + } + } + + /** + * If the player has a raised arm, lower it. + * Do it manually, bypassing the checks in the normal procedure. + * @see `AvatarAction.ObjectHeld` + * @see `AvatarServiceMessage` + * @see `Player.DrawnSlot` + * @see `Player.HandsDownSlot` + * @param tplayer the player + * @param guid target guid (usually the player) + * @param zone the zone of reporting + * @return if the hand has a drawn equipment in it and tries to lower + */ + private def forcedTolowerRaisedArm(tplayer: Player, guid:PlanetSideGUID, zone: Zone): Boolean = { + val slot = tplayer.DrawnSlot + if (slot != Player.HandsDownSlot) { + tplayer.DrawnSlot = Player.HandsDownSlot + zone.AvatarEvents ! AvatarServiceMessage( + zone.id, + AvatarAction.ObjectHeld(guid, Player.HandsDownSlot, slot) + ) + true + } else { + false + } + } + + /** + * Restriction logic that stops the player + * from lowering or raising any drawn equipment a certain number of times. + * Reset to default restriction behavior when no longer valid. + * @see `Player.neverRestrict` + * @see `Player.ResistArmMotion` + * @param count number of times to stop the player from adjusting their arm + * @param player target player + * @param slot slot being switched to (unused here) + * @return if the motion is restricted + */ + def countRestrictAttempts(count: Int)(player: Player, slot: Int): Boolean = { + if (count > 0) { + player.ResistArmMotion(countRestrictAttempts(count - 1)) + true + } else { + player.ResistArmMotion(Player.neverRestrict) //reset + false + } + } + /** * If a timeout occurs on the manipulation, declare a terminal transaction failure. * @see `AskTimeoutException` diff --git a/src/main/scala/net/psforever/objects/Player.scala b/src/main/scala/net/psforever/objects/Player.scala index 8e5e4242..43b35ba0 100644 --- a/src/main/scala/net/psforever/objects/Player.scala +++ b/src/main/scala/net/psforever/objects/Player.scala @@ -14,6 +14,7 @@ import net.psforever.objects.serverobject.environment.InteractWithEnvironment import net.psforever.objects.serverobject.mount.MountableEntity import net.psforever.objects.vital.resistance.ResistanceProfile import net.psforever.objects.vital.Vitality +import net.psforever.objects.vital.damage.DamageProfile import net.psforever.objects.vital.interaction.DamageInteraction import net.psforever.objects.vital.resolution.DamageResistanceModel import net.psforever.objects.zones.blockmap.{BlockMapEntity, SectorPopulation} @@ -79,6 +80,8 @@ class Player(var avatar: Avatar) val squadLoadouts = new LoadoutManager(10) + var resistArmMotion: (Player,Int)=>Boolean = Player.neverRestrict + //init Health = 0 //player health is artificially managed as a part of their lifecycle; start entity as dead Destroyed = true //see isAlive @@ -176,8 +179,8 @@ class Player(var avatar: Avatar) capacitorState } - def CapacitorLastUsedMillis = capacitorLastUsedMillis - def CapacitorLastChargedMillis = capacitorLastChargedMillis + def CapacitorLastUsedMillis: Long = capacitorLastUsedMillis + def CapacitorLastChargedMillis: Long = capacitorLastChargedMillis def VisibleSlots: Set[Int] = if (exosuit.SuitType == ExoSuitType.MAX) { @@ -253,7 +256,7 @@ class Player(var avatar: Avatar) } } - def FreeHand = freeHand + def FreeHand: EquipmentSlot = freeHand def FreeHand_=(item: Option[Equipment]): Option[Equipment] = { if (freeHand.Equipment.isEmpty || item.isEmpty) { @@ -313,6 +316,18 @@ class Player(var avatar: Avatar) } } + def ResistArmMotion(func: (Player,Int)=>Boolean): Unit = { + resistArmMotion = func + } + + def TestArmMotion(): Boolean = { + resistArmMotion(this, drawnSlot) + } + + def TestArmMotion(slot: Int): Boolean = { + resistArmMotion(this, slot) + } + def DrawnSlot: Int = drawnSlot def DrawnSlot_=(slot: Int): Int = { @@ -339,15 +354,15 @@ class Player(var avatar: Avatar) ChangeSpecialAbility() } - def Subtract = exosuit.Subtract + def Subtract: DamageProfile = exosuit.Subtract - def ResistanceDirectHit = exosuit.ResistanceDirectHit + def ResistanceDirectHit: Int = exosuit.ResistanceDirectHit - def ResistanceSplash = exosuit.ResistanceSplash + def ResistanceSplash: Int = exosuit.ResistanceSplash - def ResistanceAggravated = exosuit.ResistanceAggravated + def ResistanceAggravated: Int = exosuit.ResistanceAggravated - def RadiationShielding = exosuit.RadiationShielding + def RadiationShielding: Float = exosuit.RadiationShielding def FacingYawUpper: Float = facingYawUpper @@ -533,7 +548,7 @@ class Player(var avatar: Avatar) ZoningRequest } - def DamageModel = exosuit.asInstanceOf[DamageResistanceModel] + def DamageModel: DamageResistanceModel = exosuit.asInstanceOf[DamageResistanceModel] def canEqual(other: Any): Boolean = other.isInstanceOf[Player] @@ -601,6 +616,10 @@ object Player { player } } + + def neverRestrict(player: Player, slot: Int): Boolean = { + false + } } private class InteractWithMinesUnlessSpectating( diff --git a/src/main/scala/net/psforever/objects/Players.scala b/src/main/scala/net/psforever/objects/Players.scala index c577822e..cc5bdc40 100644 --- a/src/main/scala/net/psforever/objects/Players.scala +++ b/src/main/scala/net/psforever/objects/Players.scala @@ -439,13 +439,9 @@ object Players { } if (player.DrawnSlot == Player.HandsDownSlot) { player.DrawnSlot = index - events ! AvatarServiceMessage( - name, - AvatarAction.SendResponse(Service.defaultPlayerGUID, ObjectHeldMessage(pguid, index, true)) - ) events ! AvatarServiceMessage( zone.id, - AvatarAction.ObjectHeld(pguid, index) + AvatarAction.ObjectHeld(pguid, index, index) ) } } diff --git a/src/main/scala/net/psforever/objects/avatar/Avatar.scala b/src/main/scala/net/psforever/objects/avatar/Avatar.scala index 4b3e47d5..b9d35746 100644 --- a/src/main/scala/net/psforever/objects/avatar/Avatar.scala +++ b/src/main/scala/net/psforever/objects/avatar/Avatar.scala @@ -123,6 +123,7 @@ case class Avatar( fatigued: Boolean = false, certifications: Set[Certification] = Set(), implants: Seq[Option[Implant]] = Seq(None, None, None), + shortcuts: Array[Option[Shortcut]] = Array.fill[Option[Shortcut]](64)(None), locker: LockerContainer = Avatar.makeLocker(), deployables: DeployableToolbox = new DeployableToolbox(), lookingForSquad: Boolean = false, diff --git a/src/main/scala/net/psforever/objects/avatar/PlayerControl.scala b/src/main/scala/net/psforever/objects/avatar/PlayerControl.scala index 7a9c9ff9..46e90a21 100644 --- a/src/main/scala/net/psforever/objects/avatar/PlayerControl.scala +++ b/src/main/scala/net/psforever/objects/avatar/PlayerControl.scala @@ -306,10 +306,53 @@ class PlayerControl(player: Player, avatarActor: typed.ActorRef[AvatarActor.Comm case PlayerControl.SetExoSuit(exosuit: ExoSuitType.Value, subtype: Int) => setExoSuit(exosuit, subtype) + case PlayerControl.ObjectHeld(slot, updateMyHolsterArm) => + val before = player.DrawnSlot + val events = player.Zone.AvatarEvents + val resistance = player.TestArmMotion(slot) + if (resistance && !updateMyHolsterArm) { + events ! AvatarServiceMessage( + player.Name, + AvatarAction.ObjectHeld(player.GUID, before, -1) + ) + } else if (!resistance && before != slot && (player.DrawnSlot = slot) != before) { + val mySlot = if (updateMyHolsterArm) slot else -1 //use as a short-circuit + events ! AvatarServiceMessage( + player.Continent, + AvatarAction.ObjectHeld(player.GUID, mySlot, player.LastDrawnSlot) + ) + val isHolsters = player.VisibleSlots.contains(slot) + val equipment = player.Slot(slot).Equipment.orElse { player.Slot(before).Equipment } + if (isHolsters) { + equipment match { + case Some(unholsteredItem: Equipment) => + log.info(s"${player.Name} has drawn a ${unholsteredItem.Definition.Name} from its holster") + if (unholsteredItem.Definition == GlobalDefinitions.remote_electronics_kit) { + //rek beam/icon colour must match the player's correct hack level + events ! AvatarServiceMessage( + player.Continent, + AvatarAction.PlanetsideAttribute(unholsteredItem.GUID, 116, player.avatar.hackingSkillLevel()) + ) + } + case None => ; + } + } else { + equipment match { + case Some(holsteredEquipment) => + log.info(s"${player.Name} has put ${player.Sex.possessive} ${holsteredEquipment.Definition.Name} down") + case None => + log.info(s"${player.Name} lowers ${player.Sex.possessive} hand") + } + } + } + case Terminal.TerminalMessage(_, msg, order) => order match { case Terminal.BuyExosuit(exosuit, subtype) => val result = setExoSuit(exosuit, subtype) + if (exosuit == ExoSuitType.MAX) { + player.ResistArmMotion(PlayerControl.maxRestriction) + } player.Zone.AvatarEvents ! AvatarServiceMessage( player.Name, AvatarAction.TerminalOrderResult(msg.terminal_guid, msg.transaction_type, result) @@ -345,6 +388,7 @@ class PlayerControl(player: Player, avatarActor: typed.ActorRef[AvatarActor.Comm if ( Players.CertificationToUseExoSuit(player, exosuit, subtype) && (if (exosuit == ExoSuitType.MAX) { + player.ResistArmMotion(PlayerControl.maxRestriction) val weapon = GlobalDefinitions.MAXArms(subtype, player.Faction) player.avatar.purchaseCooldown(weapon) match { case Some(_) => false @@ -353,6 +397,7 @@ class PlayerControl(player: Player, avatarActor: typed.ActorRef[AvatarActor.Comm true } } else { + player.ResistArmMotion(Player.neverRestrict) true }) ) { @@ -1329,6 +1374,12 @@ object PlayerControl { /** na */ final case class SetExoSuit(exosuit: ExoSuitType.Value, subtype: Int) + /** na */ + final case class ObjectHeld(slot: Int, updateMyHolsterArm: Boolean) + object ObjectHeld { + def apply(slot: Int): ObjectHeld = ObjectHeld(slot, updateMyHolsterArm=false) + } + /** * Transform an applicable Aura effect into its `PlanetsideAttributeMessage` value. * @see `Aura` @@ -1347,4 +1398,13 @@ object PlayerControl { def sendResponse(zone: Zone, channel: String, msg: PlanetSideGamePacket): Unit = { zone.AvatarEvents ! AvatarServiceMessage(channel, AvatarAction.SendResponse(Service.defaultPlayerGUID, msg)) } + + def maxRestriction(player: Player, slot: Int): Boolean = { + if (player.ExoSuit == ExoSuitType.MAX) { + slot != 0 + } else { + player.ResistArmMotion(Player.neverRestrict) //reset + false + } + } } diff --git a/src/main/scala/net/psforever/objects/avatar/Shortcut.scala b/src/main/scala/net/psforever/objects/avatar/Shortcut.scala new file mode 100644 index 00000000..0251ad7a --- /dev/null +++ b/src/main/scala/net/psforever/objects/avatar/Shortcut.scala @@ -0,0 +1,76 @@ +// Copyright (c) 2022 PSForever +package net.psforever.objects.avatar + +import net.psforever.packet.game.{Shortcut => GameShortcut} + +/** + * The internal respresentation of a shortcut on the hotbar. + * @param purpose integer value related to the type of shortcut + * @param tile details how the shortcut is to be used (net.psforever.packet.game.Schortcut) + * @param effect1 three letters emblazoned on the shortcut icon; + * defaults to empty string + * @param effect2 the message published to text chat; + * defaults to empty string + */ +case class Shortcut( + purpose: Int, + tile: String, + effect1: String = "", + effect2: String = "" + ) + +object Shortcut { + /** + * Transform the internal form of the `Shortcut` + * into the packet form of the `Shortcut`. + * @see `net.psforever.packet.game.Shortcut` + * @param shortcut internal form of the `Shortcut` + * @return equivalent packet form of the `Shortcut` + * @throws `AssertionError` if an implant is not named + */ + def convert(shortcut: Shortcut): GameShortcut = { + shortcut.tile match { + case "medkit" => GameShortcut.Medkit() + case "shortcut_macro" => GameShortcut.Macro(shortcut.effect1, shortcut.effect2) + case _ => GameShortcut.Implant(shortcut.tile) + } + } + + /** + * Is an internal form of the `Shortcut` equivalent to a packet form of the `Shortcut`? + * @param a internal form of the `Shortcut` + * @param b packet form of the `Shortcut` + * @return `true`, if the forms of `Shortcut` are equivalent; + * `false`, otherwise + */ + def equals(a: Shortcut, b: GameShortcut): Boolean = { + a.purpose == b.code && typeEquals(a, b) + } + /** + * Is an internal form of the `Shortcut` equivalent to a packet form of the `Shortcut`? + * @param a internal form of the `Shortcut` + * @param b packet form of the `Shortcut` + * @return `true`, if the forms of `Shortcut` are equivalent; + * `false`, otherwise + */ + def equals(b: GameShortcut, a: Shortcut): Boolean = { + a.purpose == b.code && typeEquals(a, b) + } + + /** + * Is an internal form of the `Shortcut` equivalent to a packet form of the `Shortcut`? + * Test against individual types of packet forms and then the fields associated with that form. + * @param a internal form of the `Shortcut` + * @param b packet form of the `Shortcut` + * @return `true`, if the forms of `Shortcut` are equivalent; + * `false`, otherwise + */ + private def typeEquals(a: Shortcut, b: GameShortcut): Boolean = { + b match { + case GameShortcut.Medkit() => true + case GameShortcut.Macro(x, y) => x.equals(a.effect1) && y.equals(a.effect2) + case GameShortcut.Implant(tile) => tile.equals(a.tile) + case _ => true + } + } +} diff --git a/src/main/scala/net/psforever/packet/game/CreateShortcutMessage.scala b/src/main/scala/net/psforever/packet/game/CreateShortcutMessage.scala index ec4b1476..ba518fc2 100644 --- a/src/main/scala/net/psforever/packet/game/CreateShortcutMessage.scala +++ b/src/main/scala/net/psforever/packet/game/CreateShortcutMessage.scala @@ -2,9 +2,10 @@ package net.psforever.packet.game import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket} -import net.psforever.types.PlanetSideGUID -import scodec.Codec +import net.psforever.types.{ImplantType, PlanetSideGUID} +import scodec.{Codec, TransformSyntax} import scodec.codecs._ +import shapeless.{::, HNil} /** * Details regarding this shortcut.
@@ -18,33 +19,24 @@ import scodec.codecs._ * The `shortcut_macro` setting displays a word bubble superimposed by the (first three letters of) `effect1` text.
* Implants and the medkit should have self-explanatory graphics. *
- * Purpose:
- * `0 - Medkit`
- * `1 - Macro`
- * `2 - Implant`
- *
- * Tile:
- * `advanced_regen` (regeneration)
- * `audio_amplifier`
- * `darklight_vision`
- * `medkit`
- * `melee_booster`
- * `personal_shield`
- * `range_magnifier`
- * `second_wind`
- * `shortcut_macro`
- * `silent_run` (sensor shield)
- * `surge`
- * `targeting` (enhanced targetting)
- *
- * Exploration:
- * What is `purpose` when 3? - * @param purpose the primary use of this shortcut - * @param tile the visual element of the shortcut - * @param effect1 for macros, a three letter acronym displayed in the hotbar - * @param effect2 for macros, the chat message content + * Tile - Code
+ * `advanced_regen` (regeneration) - 2
+ * `audio_amplifier` - 2
+ * `darklight_vision` - 2
+ * `medkit` - 0
+ * `melee_booster` - 2
+ * `personal_shield` - 2
+ * `range_magnifier` - 2
+ * `second_wind` - 2
+ * `shortcut_macro` - 1
+ * `silent_run` (sensor shield) - 2
+ * `surge` - 2
+ * `targeting` (enhanced targeting) - 2 + * @param code the primary use of this shortcut */ -final case class Shortcut(purpose: Int, tile: String, effect1: String = "", effect2: String = "") +abstract class Shortcut(val code: Int) { + def tile: String +} /** * Facilitate a quick-use button for the hotbar.
@@ -64,17 +56,13 @@ final case class Shortcut(purpose: Int, tile: String, effect1: String = "", effe * The prior functionality will rarely be appreciated, however, as players rarely never have their medkit shortcut unbound. * @param player_guid the player * @param slot the hotbar slot number (one-indexed) - * @param unk na; always zero? - * @param addShortcut true, if we are adding a shortcut; false, if we are removing any current shortcut * @param shortcut optional; details about the shortcut to be created - * @see ChangeShortcutBankMessage + * @see `ChangeShortcutBankMessage` */ final case class CreateShortcutMessage( player_guid: PlanetSideGUID, slot: Int, - unk: Int, - addShortcut: Boolean, - shortcut: Option[Shortcut] = None + shortcut: Option[Shortcut] ) extends PlanetSideGamePacket { type Packet = CreateShortcutMessage def opcode = GamePacketOpcode.CreateShortcutMessage @@ -82,33 +70,110 @@ final case class CreateShortcutMessage( } object Shortcut extends Marshallable[Shortcut] { - /** Preset for the medkit quick-use option. */ - final val Medkit: Some[Shortcut] = Some(Shortcut(0, "medkit")) + final case class Medkit() extends Shortcut(code=0) { + def tile = "medkit" + } /** - * Converter for text macro parameters that acts like a preset. - * @param effect1 a three letter acronym displayed in the hotbar - * @param effect2 the chat message content - * @return `Some` shortcut that represents a voice macro command - */ - def MACRO(effect1: String, effect2: String): Some[Shortcut] = Some(Shortcut(1, "shortcut_macro", effect1, effect2)) + * Converter for text macro parameters that acts like a preset. + * @param acronym a three letter acronym displayed in the hotbar + * @param msg the chat message content + * @return `Some` shortcut that represents a voice macro command + */ + final case class Macro(acronym: String, msg: String) extends Shortcut(code=1) { + override val tile: String = "shortcut_macro" + } - implicit val codec: Codec[Shortcut] = ( - ("purpose" | uint2L) :: - ("tile" | PacketHelpers.encodedStringAligned(5)) :: + /** + * Converter for an implant name token that acts like a preset. + * @param tile implant name + * @return `Some` shortcut that represents an implant + * @throws `AssertionError` if an implant is not named + */ + final case class Implant(tile: String) extends Shortcut(code=2) { + assert(ImplantType.names.exists(_.equals(tile)), s"not an implant - $tile") + } + + /** + * Main transcoder for medkit shortcuts. + */ + val medkitCodec: Codec[Medkit] = ( + ("tile" | PacketHelpers.encodedStringAligned(adjustment=5)) :: ("effect1" | PacketHelpers.encodedWideString) :: ("effect2" | PacketHelpers.encodedWideString) - ).as[Shortcut] + ).xmap[Medkit]( + _ => Medkit(), + { + case Medkit() => "medkit" :: "" :: "" :: HNil + } + ) + + /** + * Main transcoder for text chat macro shortcuts. + * All strings transcoders are utilized. + */ + val macroCodec: Codec[Macro] = ( + ("tile" | PacketHelpers.encodedStringAligned(adjustment=5)) :: + ("effect1" | PacketHelpers.encodedWideString) :: + ("effect2" | PacketHelpers.encodedWideString) + ).xmap[Macro]( + { + case _ :: acronym :: msg :: HNil => Macro(acronym, msg) + }, + { + case Macro(acronym, msg) => "shortcut_macro" :: acronym :: msg :: HNil + } + ) + + /** + * Main transcoder for implant quick-use shortcuts. + */ + val implantCodec: Codec[Implant] = ( + ("tile" | PacketHelpers.encodedStringAligned(adjustment=5)) :: + ("effect1" | PacketHelpers.encodedWideString) :: + ("effect2" | PacketHelpers.encodedWideString) + ).xmap[Implant]( + { + case implant :: _ :: _ :: HNil => Implant(implant) + }, + { + case Implant(implant) => implant :: "" :: "" :: HNil + } + ) + + /** + * Convert the numeric flag for a specific kind of shortcut into the transcoder for that kind of shortcut. + * @param code numeric code for shortcut type + * @return transcoder for that shortcut type + * @throws IllegalArgumentException if the numeric code does not map to any valid transcoders + */ + def shortcutSwitch(code: Int): Codec[Shortcut] = { + (code match { + case 0 => medkitCodec + case 1 => macroCodec + case 2 => implantCodec + case _ => throw new IllegalArgumentException(s"code not associated with shortcut type - $code") + }).asInstanceOf[Codec[Shortcut]] + } + + implicit val codec: Codec[Shortcut] = ( + uint(bits=2) >>:~ { code => + shortcutSwitch(code).hlist + }).xmap[Shortcut]( + { + case _ :: shortcut :: HNil => shortcut + }, + { + shortcut => shortcut.code :: shortcut :: HNil + } + ) } object CreateShortcutMessage extends Marshallable[CreateShortcutMessage] { implicit val codec: Codec[CreateShortcutMessage] = ( ("player_guid" | PlanetSideGUID.codec) :: - ("slot" | uint8L) :: - ("unk" | uint8L) :: - (("addShortcut" | bool) >>:~ { value => - conditional(value, "shortcut" | Shortcut.codec).hlist - }) + ("slot" | uint16L) :: + ("shortcut" | optional(bool, Shortcut.codec)) ).as[CreateShortcutMessage] } diff --git a/src/main/scala/net/psforever/persistence/Shortcut.scala b/src/main/scala/net/psforever/persistence/Shortcut.scala new file mode 100644 index 00000000..5d33ebde --- /dev/null +++ b/src/main/scala/net/psforever/persistence/Shortcut.scala @@ -0,0 +1,11 @@ +// Copyright (c) 2022 PSForever +package net.psforever.persistence + +case class Shortcut( + avatarId: Long, + slot: Int, + purpose: Int, + tile: String, + effect1: Option[String] = None, + effect2: Option[String] = None + ) diff --git a/src/main/scala/net/psforever/services/avatar/AvatarService.scala b/src/main/scala/net/psforever/services/avatar/AvatarService.scala index 4d1abe34..c17c9cc1 100644 --- a/src/main/scala/net/psforever/services/avatar/AvatarService.scala +++ b/src/main/scala/net/psforever/services/avatar/AvatarService.scala @@ -173,9 +173,9 @@ class AvatarService(zone: Zone) extends Actor { AvatarEvents.publish( AvatarServiceResponse(s"/$forChannel/Avatar", player_guid, AvatarResponse.ObjectDelete(item_guid, unk)) ) - case AvatarAction.ObjectHeld(player_guid, slot) => + case AvatarAction.ObjectHeld(player_guid, slot, previousSlot) => AvatarEvents.publish( - AvatarServiceResponse(s"/$forChannel/Avatar", player_guid, AvatarResponse.ObjectHeld(slot)) + AvatarServiceResponse(s"/$forChannel/Avatar", player_guid, AvatarResponse.ObjectHeld(slot, previousSlot)) ) case AvatarAction.OxygenState(player, vehicle) => AvatarEvents.publish( diff --git a/src/main/scala/net/psforever/services/avatar/AvatarServiceMessage.scala b/src/main/scala/net/psforever/services/avatar/AvatarServiceMessage.scala index c36d7f56..d5335f2a 100644 --- a/src/main/scala/net/psforever/services/avatar/AvatarServiceMessage.scala +++ b/src/main/scala/net/psforever/services/avatar/AvatarServiceMessage.scala @@ -67,7 +67,7 @@ object AvatarAction { cdata: ConstructorData ) extends Action final case class ObjectDelete(player_guid: PlanetSideGUID, item_guid: PlanetSideGUID, unk: Int = 0) extends Action - final case class ObjectHeld(player_guid: PlanetSideGUID, slot: Int) extends Action + final case class ObjectHeld(player_guid: PlanetSideGUID, slot: Int, previousSLot: Int) extends Action final case class OxygenState(player: OxygenStateTarget, vehicle: Option[OxygenStateTarget]) extends Action final case class PlanetsideAttribute(player_guid: PlanetSideGUID, attribute_type: Int, attribute_value: Long) extends Action diff --git a/src/main/scala/net/psforever/services/avatar/AvatarServiceResponse.scala b/src/main/scala/net/psforever/services/avatar/AvatarServiceResponse.scala index fb06956a..440d4bc2 100644 --- a/src/main/scala/net/psforever/services/avatar/AvatarServiceResponse.scala +++ b/src/main/scala/net/psforever/services/avatar/AvatarServiceResponse.scala @@ -47,7 +47,7 @@ object AvatarResponse { final case class LoadPlayer(pkt: ObjectCreateMessage) extends Response final case class LoadProjectile(pkt: ObjectCreateMessage) extends Response final case class ObjectDelete(item_guid: PlanetSideGUID, unk: Int) extends Response - final case class ObjectHeld(slot: Int) extends Response + final case class ObjectHeld(slot: Int, previousSLot: Int) extends Response final case class OxygenState(player: OxygenStateTarget, vehicle: Option[OxygenStateTarget]) extends Response final case class PlanetsideAttribute(attribute_type: Int, attribute_value: Long) extends Response final case class PlanetsideAttributeToAll(attribute_type: Int, attribute_value: Long) extends Response diff --git a/src/main/scala/net/psforever/types/ImplantType.scala b/src/main/scala/net/psforever/types/ImplantType.scala index 10cd0fc2..845e2ac4 100644 --- a/src/main/scala/net/psforever/types/ImplantType.scala +++ b/src/main/scala/net/psforever/types/ImplantType.scala @@ -5,6 +5,7 @@ import enumeratum.values.{IntEnum, IntEnumEntry} import net.psforever.packet.PacketHelpers import net.psforever.packet.game.Shortcut import net.psforever.packet.game.objectcreate.ImplantEffects +import scodec.Codec import scodec.codecs._ sealed abstract class ImplantType( @@ -16,52 +17,60 @@ sealed abstract class ImplantType( ) extends IntEnumEntry case object ImplantType extends IntEnum[ImplantType] { - case object AdvancedRegen extends ImplantType( value = 0, - shortcut = Shortcut(2, "advanced_regen"), + shortcut = Shortcut.Implant("advanced_regen"), effect = Some(ImplantEffects.RegenEffects) ) - case object Targeting extends ImplantType(value = 1, shortcut = Shortcut(2, "targeting")) + case object Targeting extends ImplantType(value = 1, shortcut = Shortcut.Implant("targeting")) - case object AudioAmplifier extends ImplantType(value = 2, shortcut = Shortcut(2, "audio_amplifier")) + case object AudioAmplifier extends ImplantType(value = 2, shortcut = Shortcut.Implant("audio_amplifier")) case object DarklightVision extends ImplantType( value = 3, - shortcut = Shortcut(2, "darklight_vision"), + shortcut = Shortcut.Implant("darklight_vision"), effect = Some(ImplantEffects.DarklightEffects) ) - case object MeleeBooster extends ImplantType(value = 4, shortcut = Shortcut(2, "melee_booster")) + case object MeleeBooster extends ImplantType(value = 4, shortcut = Shortcut.Implant("melee_booster")) case object PersonalShield extends ImplantType( value = 5, - shortcut = Shortcut(2, "personal_shield"), + shortcut = Shortcut.Implant("personal_shield"), disabledFor = Set(ExoSuitType.Infiltration), effect = Some(ImplantEffects.PersonalShieldEffects) ) - case object RangeMagnifier extends ImplantType(value = 6, shortcut = Shortcut(2, "range_magnifier")) + case object RangeMagnifier extends ImplantType(value = 6, shortcut = Shortcut.Implant("range_magnifier")) - case object SecondWind extends ImplantType(value = 7, shortcut = Shortcut(2, "second_wind")) + case object SecondWind extends ImplantType(value = 7, shortcut = Shortcut.Implant("second_wind")) - case object SilentRun extends ImplantType(value = 8, shortcut = Shortcut(2, "silent_run")) + case object SilentRun extends ImplantType(value = 8, shortcut = Shortcut.Implant("silent_run")) - case object Surge - extends ImplantType( - value = 9, - shortcut = Shortcut(2, "surge"), - disabledFor = Set(ExoSuitType.MAX), - effect = Some(ImplantEffects.SurgeEffects) - ) + case object Surge extends ImplantType( + value = 9, + shortcut = Shortcut.Implant("surge"), + disabledFor = Set(ExoSuitType.MAX), + effect = Some(ImplantEffects.SurgeEffects) + ) - case object None extends ImplantType(value = 15, shortcut = Shortcut(2, "")) + case object None extends ImplantType( + value = 15, + shortcut = Shortcut.Macro(acronym="ERR", msg=""), + disabledFor = ExoSuitType.values + ) def values: IndexedSeq[ImplantType] = findValues - implicit val codec = PacketHelpers.createIntEnumCodec(this, uint4L) + final val names: Seq[String] = Seq( + "advanced_regen", "targeting", "audio_amplifier", + "darklight_vision", "melee_booster", "personal_shield", "range_magnifier", + "second_wind", "silent_run", "surge" + ) + + implicit val codec: Codec[ImplantType] = PacketHelpers.createIntEnumCodec(this, uint4L) } diff --git a/src/test/scala/game/CreateShortcutMessageTest.scala b/src/test/scala/game/CreateShortcutMessageTest.scala index 5a8ad92d..2ac619e7 100644 --- a/src/test/scala/game/CreateShortcutMessageTest.scala +++ b/src/test/scala/game/CreateShortcutMessageTest.scala @@ -15,16 +15,13 @@ class CreateShortcutMessageTest extends Specification { "decode (medkit)" in { PacketCoding.decodePacket(stringMedkit).require match { - case CreateShortcutMessage(player_guid, slot, unk, addShortcut, shortcut) => + case CreateShortcutMessage(player_guid, slot, shortcut) => player_guid mustEqual PlanetSideGUID(4210) slot mustEqual 1 - unk mustEqual 0 - addShortcut mustEqual true - shortcut.isDefined mustEqual true - shortcut.get.purpose mustEqual 0 - shortcut.get.tile mustEqual "medkit" - shortcut.get.effect1 mustEqual "" - shortcut.get.effect2 mustEqual "" + shortcut match { + case Some(Shortcut.Medkit()) => ok + case _ => ko + } case _ => ko } @@ -32,16 +29,13 @@ class CreateShortcutMessageTest extends Specification { "decode (macro)" in { PacketCoding.decodePacket(stringMacro).require match { - case CreateShortcutMessage(player_guid, slot, unk, addShortcut, shortcut) => + case CreateShortcutMessage(player_guid, slot, shortcut) => player_guid mustEqual PlanetSideGUID(1356) slot mustEqual 8 - unk mustEqual 0 - addShortcut mustEqual true - shortcut.isDefined mustEqual true - shortcut.get.purpose mustEqual 1 - shortcut.get.tile mustEqual "shortcut_macro" - shortcut.get.effect1 mustEqual "NTU" - shortcut.get.effect2 mustEqual "/platoon Incoming NTU spam!" + shortcut match { + case Some(Shortcut.Macro("NTU", "/platoon Incoming NTU spam!")) => ok + case _ => ko + } case _ => ko } @@ -49,11 +43,9 @@ class CreateShortcutMessageTest extends Specification { "decode (remove)" in { PacketCoding.decodePacket(stringRemove).require match { - case CreateShortcutMessage(player_guid, slot, unk, addShortcut, shortcut) => + case CreateShortcutMessage(player_guid, slot, shortcut) => player_guid mustEqual PlanetSideGUID(1356) slot mustEqual 1 - unk mustEqual 0 - addShortcut mustEqual false shortcut.isDefined mustEqual false case _ => ko @@ -61,7 +53,7 @@ class CreateShortcutMessageTest extends Specification { } "encode (medkit)" in { - val msg = CreateShortcutMessage(PlanetSideGUID(4210), 1, 0, true, Some(Shortcut(0, "medkit"))) + val msg = CreateShortcutMessage(PlanetSideGUID(4210), 1, Some(Shortcut.Medkit())) val pkt = PacketCoding.encodePacket(msg).require.toByteVector pkt mustEqual stringMedkit @@ -71,9 +63,7 @@ class CreateShortcutMessageTest extends Specification { val msg = CreateShortcutMessage( PlanetSideGUID(1356), 8, - 0, - true, - Some(Shortcut(1, "shortcut_macro", "NTU", "/platoon Incoming NTU spam!")) + Some(Shortcut.Macro("NTU", "/platoon Incoming NTU spam!")) ) val pkt = PacketCoding.encodePacket(msg).require.toByteVector @@ -81,42 +71,40 @@ class CreateShortcutMessageTest extends Specification { } "encode (remove)" in { - val msg = CreateShortcutMessage(PlanetSideGUID(1356), 1, 0, false) + val msg = CreateShortcutMessage(PlanetSideGUID(1356), 1, None) val pkt = PacketCoding.encodePacket(msg).require.toByteVector pkt mustEqual stringRemove } "macro" in { - val MACRO: Some[Shortcut] = Shortcut.MACRO("NTU", "/platoon Incoming NTU spam!") - MACRO.get.purpose mustEqual 1 - MACRO.get.tile mustEqual "shortcut_macro" - MACRO.get.effect1 mustEqual "NTU" - MACRO.get.effect2 mustEqual "/platoon Incoming NTU spam!" + val MACRO: Shortcut.Macro = Shortcut.Macro("NTU", "/platoon Incoming NTU spam!") + MACRO.acronym mustEqual "NTU" + MACRO.msg mustEqual "/platoon Incoming NTU spam!" } "presets" in { - ImplantType.AudioAmplifier.shortcut.purpose mustEqual 2 + ImplantType.AudioAmplifier.shortcut.code mustEqual 2 ImplantType.AudioAmplifier.shortcut.tile mustEqual "audio_amplifier" - ImplantType.DarklightVision.shortcut.purpose mustEqual 2 + ImplantType.DarklightVision.shortcut.code mustEqual 2 ImplantType.DarklightVision.shortcut.tile mustEqual "darklight_vision" - ImplantType.Targeting.shortcut.purpose mustEqual 2 + ImplantType.Targeting.shortcut.code mustEqual 2 ImplantType.Targeting.shortcut.tile mustEqual "targeting" - Shortcut.Medkit.get.purpose mustEqual 0 - Shortcut.Medkit.get.tile mustEqual "medkit" - ImplantType.MeleeBooster.shortcut.purpose mustEqual 2 + Shortcut.Medkit().code mustEqual 0 + Shortcut.Medkit().tile mustEqual "medkit" + ImplantType.MeleeBooster.shortcut.code mustEqual 2 ImplantType.MeleeBooster.shortcut.tile mustEqual "melee_booster" - ImplantType.PersonalShield.shortcut.purpose mustEqual 2 + ImplantType.PersonalShield.shortcut.code mustEqual 2 ImplantType.PersonalShield.shortcut.tile mustEqual "personal_shield" - ImplantType.RangeMagnifier.shortcut.purpose mustEqual 2 + ImplantType.RangeMagnifier.shortcut.code mustEqual 2 ImplantType.RangeMagnifier.shortcut.tile mustEqual "range_magnifier" - ImplantType.AdvancedRegen.shortcut.purpose mustEqual 2 + ImplantType.AdvancedRegen.shortcut.code mustEqual 2 ImplantType.AdvancedRegen.shortcut.tile mustEqual "advanced_regen" - ImplantType.SecondWind.shortcut.purpose mustEqual 2 + ImplantType.SecondWind.shortcut.code mustEqual 2 ImplantType.SecondWind.shortcut.tile mustEqual "second_wind" - ImplantType.SilentRun.shortcut.purpose mustEqual 2 + ImplantType.SilentRun.shortcut.code mustEqual 2 ImplantType.SilentRun.shortcut.tile mustEqual "silent_run" - ImplantType.Surge.shortcut.purpose mustEqual 2 + ImplantType.Surge.shortcut.code mustEqual 2 ImplantType.Surge.shortcut.tile mustEqual "surge" } }