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
This commit is contained in:
Fate-JH 2022-10-24 18:16:08 -04:00 committed by GitHub
parent 1369da22f0
commit 630c2809cb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 1032 additions and 313 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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.<br>
* <br>
* This is not vanilla behavior.<br>
* <br>
* 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`

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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.<br>
@ -18,33 +19,24 @@ import scodec.codecs._
* The `shortcut_macro` setting displays a word bubble superimposed by the (first three letters of) `effect1` text.<br>
* Implants and the medkit should have self-explanatory graphics.
* <br>
* Purpose:<br>
* `0 - Medkit`<br>
* `1 - Macro`<br>
* `2 - Implant`<br>
* <br>
* Tile:<br>
* `advanced_regen` (regeneration)<br>
* `audio_amplifier`<br>
* `darklight_vision`<br>
* `medkit`<br>
* `melee_booster`<br>
* `personal_shield`<br>
* `range_magnifier`<br>
* `second_wind`<br>
* `shortcut_macro`<br>
* `silent_run` (sensor shield)<br>
* `surge`<br>
* `targeting` (enhanced targetting)<br>
* <br>
* Exploration:<br>
* 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<br>
* `advanced_regen` (regeneration) - 2<br>
* `audio_amplifier` - 2<br>
* `darklight_vision` - 2<br>
* `medkit` - 0<br>
* `melee_booster` - 2<br>
* `personal_shield` - 2<br>
* `range_magnifier` - 2<br>
* `second_wind` - 2<br>
* `shortcut_macro` - 1<br>
* `silent_run` (sensor shield) - 2<br>
* `surge` - 2<br>
* `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.<br>
@ -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]
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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