mirror of
https://github.com/2revoemag/PSF-BotServer.git
synced 2026-01-19 18:14:44 +00:00
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:
parent
1369da22f0
commit
630c2809cb
|
|
@ -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)
|
||||
);
|
||||
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
76
src/main/scala/net/psforever/objects/avatar/Shortcut.scala
Normal file
76
src/main/scala/net/psforever/objects/avatar/Shortcut.scala
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
11
src/main/scala/net/psforever/persistence/Shortcut.scala
Normal file
11
src/main/scala/net/psforever/persistence/Shortcut.scala
Normal 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
|
||||
)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue