diff --git a/server/src/main/resources/db/migration/V005__Acquaintances.sql b/server/src/main/resources/db/migration/V005__Acquaintances.sql
new file mode 100644
index 00000000..00106609
--- /dev/null
+++ b/server/src/main/resources/db/migration/V005__Acquaintances.sql
@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS "friend" (
+ "id" SERIAL PRIMARY KEY NOT NULL,
+ "avatar_id" INT NOT NULL REFERENCES avatar (id),
+ "char_id" INT NOT NULL REFERENCES avatar (id)
+);
+
+CREATE TABLE IF NOT EXISTS "ignored" (
+ "id" SERIAL PRIMARY KEY NOT NULL,
+ "avatar_id" INT NOT NULL REFERENCES avatar (id),
+ "char_id" INT NOT NULL REFERENCES avatar (id)
+);
diff --git a/src/main/scala/net/psforever/actors/session/AvatarActor.scala b/src/main/scala/net/psforever/actors/session/AvatarActor.scala
index 45ca034c..48495999 100644
--- a/src/main/scala/net/psforever/actors/session/AvatarActor.scala
+++ b/src/main/scala/net/psforever/actors/session/AvatarActor.scala
@@ -5,7 +5,14 @@ import java.util.concurrent.atomic.AtomicInteger
import akka.actor.Cancellable
import akka.actor.typed.scaladsl.{ActorContext, Behaviors, StashBuffer}
import akka.actor.typed.{ActorRef, Behavior, PostStop, SupervisorStrategy}
-import net.psforever.objects.avatar._
+import org.joda.time.{LocalDateTime, Seconds}
+//import org.log4s.Logger
+import scala.collection.mutable
+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.definition.converter.CharacterSelectConverter
import net.psforever.objects.definition._
import net.psforever.objects.inventory.Container
@@ -17,22 +24,14 @@ import net.psforever.objects.ballistics.PlayerSource
import net.psforever.objects.locker.LockerContainer
import net.psforever.objects.vital.HealFromImplant
import net.psforever.packet.game.objectcreate.{ObjectClass, RibbonBars}
-import net.psforever.packet.game._
-import net.psforever.types._
+import net.psforever.packet.game.{Friend => GameFriend, _}
+import net.psforever.types.{MemberAction, PlanetSideEmpire, _}
import net.psforever.util.Database._
import net.psforever.persistence
import net.psforever.util.{Config, Database, DefinitionUtil}
-import org.joda.time.{LocalDateTime, Seconds}
-import net.psforever.services.ServiceManager
+import net.psforever.services.{Service, ServiceManager}
import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage}
-import scala.collection.mutable
-import scala.concurrent.{ExecutionContextExecutor, Future, Promise}
-import scala.util.{Failure, Success}
-import scala.concurrent.duration._
-import net.psforever.services.Service
-import org.log4s.Logger
-
object AvatarActor {
def apply(sessionActor: ActorRef[SessionActor.Command]): Behavior[Command] =
Behaviors
@@ -187,6 +186,8 @@ object AvatarActor {
private case class SetImplantInitialized(implantType: ImplantType) extends Command
+ final case class MemberListRequest(action: MemberAction.Value, name: String) extends Command
+
final case class AvatarResponse(avatar: Avatar)
final case class AvatarLoginResponse(avatar: Avatar)
@@ -286,7 +287,7 @@ object AvatarActor {
}
def encodeLockerClob(container: Container): String = {
- val clobber: StringBuilder = new StringBuilder()
+ val clobber: mutable.StringBuilder = new StringBuilder()
container.Inventory.Items.foreach {
case InventoryItem(obj, index) =>
clobber.append(encodeLoadoutClobFragment(obj, index))
@@ -315,6 +316,121 @@ object AvatarActor {
case RibbonBarSlot.TermOfService => ribbons.copy(tos = ribbon)
}
}
+
+ /**
+ * Check for an avatar being online at the moment by matching against their name.
+ * If discovered, run a function based on the avatar's characteristics.
+ * @param name name of a character being sought
+ * @param func functionality that is called upon discovery of the character
+ * @return if found, the discovered avatar, the avatar's account id, and the avatar's faction affiliation
+ */
+ def getLiveAvatarForFunc(name: String, func: (Long,String,Int)=>Unit): Option[(Avatar, Long, PlanetSideEmpire.Value)] = {
+ if (name.nonEmpty) {
+ LivePlayerList.WorldPopulation({ case (_, a) => a.name.equals(name) }).headOption match {
+ case Some(otherAvatar) =>
+ func(otherAvatar.id, name, otherAvatar.faction.id)
+ Some((otherAvatar, otherAvatar.id.toLong, otherAvatar.faction))
+ case None =>
+ None
+ }
+ } else {
+ None
+ }
+ }
+
+ /**
+ * Check for an avatar existing the database of avatars by matching against their name.
+ * If discovered, run a function based on the avatar's characteristics.
+ * @param name name of a character being sought
+ * @param func functionality that is called upon discovery of the character
+ * @return if found online, the discovered avatar, the avatar's account id, and the avatar's faction affiliation;
+ * otherwise, always returns `None` as if no avatar was discovered
+ * (the query is probably still in progress)
+ */
+ def getAvatarForFunc(name: String, func: (Long,String,Int)=>Unit): Option[(Avatar, Long, PlanetSideEmpire.Value)] = {
+ getLiveAvatarForFunc(name, func).orElse {
+ if (name.nonEmpty) {
+ import ctx._
+ import scala.concurrent.ExecutionContext.Implicits.global
+ ctx.run(query[persistence.Avatar].filter { _.name.equals(lift(name)) }).onComplete {
+ case Success(otherAvatar) =>
+ otherAvatar.headOption match {
+ case Some(a) =>
+ func(a.id, a.name, a.factionId)
+ case _ => ;
+ }
+ case _ => ;
+ }
+ }
+ None //satisfy the orElse
+ }
+ }
+
+ /**
+ * Transform a `(Long, String, PlanetSideEmpire.Value)` function call
+ * into a `(Long, String)` function call.
+ * @param func replacement `(Long, String)` function call
+ * @param charId unique account identifier
+ * @param name unique character name
+ * @param faction the faction affiliation
+ */
+ def formatForOtherFunc(func: (Long,String)=>Unit)(charId: Long, name: String, faction: Int): Unit = {
+ func(charId, name)
+ }
+
+ /**
+ * Determine if one player considered online to the other person.
+ * @param onlinePlayerName name of a player to be determined if they are online
+ * @param observerName name of a player who might see the former and be seen by the former
+ * @return `true`, if one player is visible to the other
+ * `false`, otherwise
+ */
+ def onlineIfNotIgnored(onlinePlayerName: String, observerName: String): Boolean = {
+ LivePlayerList.WorldPopulation({ case (_, a) => a.name.equals(onlinePlayerName) }).headOption match {
+ case Some(onlinePlayer) => onlineIfNotIgnored(onlinePlayer, observerName)
+ case _ => false
+ }
+ }
+
+ /**
+ * Determine if one player considered online to the other person.
+ * Neither player can be ignoring the other.
+ * @param onlinePlayerName name of a player to be determined if they are online
+ * @param observer player who might see the former and be seen by the former
+ * @return `true`, if one player is visible to the other
+ * `false`, otherwise
+ */
+ def onlineIfNotIgnoredEitherWay(observer: Avatar, onlinePlayerName: String): Boolean = {
+ LivePlayerList.WorldPopulation({ case (_, a) => a.name.equals(onlinePlayerName) }) match {
+ case Nil => false //weird case, but ...
+ case onlinePlayer :: Nil => onlineIfNotIgnoredEitherWay(onlinePlayer, observer)
+ case _ => throw new Exception("only trying to find two players, but too many matching search results!")
+ }
+ }
+
+ /**
+ * Determine if one player considered online to the other person.
+ * Neither player can be ignoring the other.
+ * @param onlinePlayer player who is online
+ * @param observer player who might see the former
+ * @return `true`, if the other person is not ignoring us;
+ * `false`, otherwise
+ */
+ def onlineIfNotIgnoredEitherWay(onlinePlayer: Avatar, observer: Avatar): Boolean = {
+ onlineIfNotIgnored(onlinePlayer, observer.name) && onlineIfNotIgnored(observer, onlinePlayer.name)
+ }
+
+ /**
+ * Determine if one player is considered online to the other person.
+ * The question is whether first player is ignoring the other player.
+ * @param onlinePlayer player who is online
+ * @param observedName name of the player who may be seen
+ * @return `true`, if the other person is visible;
+ * `false`, otherwise
+ */
+ def onlineIfNotIgnored(onlinePlayer: Avatar, observedName: String): Boolean = {
+ !onlinePlayer.people.ignored.exists { f => f.name.equals(observedName) }
+ }
}
class AvatarActor(
@@ -487,22 +603,26 @@ class AvatarActor(
query[persistence.Avatar].filter(_.id == lift(avatar.id))
.update(_.lastLogin -> lift(LocalDateTime.now()))
)
+ avatarId = avatar.id
loadouts <- initializeAllLoadouts()
- implants <- ctx.run(query[persistence.Implant].filter(_.avatarId == lift(avatar.id)))
- certs <- ctx.run(query[persistence.Certification].filter(_.avatarId == lift(avatar.id)))
- locker <- loadLocker()
- } yield (loadouts, implants, certs, locker)
+ 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)
+ } yield (loadouts, implants, certs, locker, friends, ignored)
result.onComplete {
- case Success((loadouts, implants, certs, locker)) =>
+ case Success((_loadouts, implants, certs, locker, friendsList, ignoredList)) =>
avatarCopy(
avatar.copy(
- loadouts = loadouts,
+ loadouts = avatar.loadouts.copy(suit = _loadouts),
// make sure we always have the base certifications
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
+ locker = locker,
+ people = MemberLists(friendsList, ignoredList)
)
)
// if we need to start stamina regeneration
@@ -523,7 +643,8 @@ class AvatarActor(
Behaviors.same
case AddFirstTimeEvent(event) =>
- avatarCopy(avatar.copy(firstTimeEvents = avatar.firstTimeEvents ++ Set(event)))
+ val decor = avatar.decoration
+ avatarCopy(avatar.copy(decoration = decor.copy(firstTimeEvents = decor.firstTimeEvents ++ Set(event))))
Behaviors.same
case LearnCertification(terminalGuid, certification) =>
@@ -814,7 +935,8 @@ class AvatarActor(
}
result.onComplete {
case Success(loadout) =>
- replaceAvatar(avatar.copy(loadouts = avatar.loadouts.updated(lineNo, Some(loadout))))
+ val ldouts = avatar.loadouts
+ replaceAvatar(avatar.copy(loadouts = ldouts.copy(suit = ldouts.suit.updated(lineNo, Some(loadout)))))
refreshLoadout(lineNo)
case Failure(exception) =>
log.error(exception)("db failure (?)")
@@ -825,7 +947,7 @@ class AvatarActor(
log.info(s"${player.Name} wishes to delete a favorite $loadoutType loadout - #${number + 1}")
import ctx._
val (lineNo, result) = loadoutType match {
- case LoadoutType.Infantry if avatar.loadouts(number).nonEmpty =>
+ case LoadoutType.Infantry if avatar.loadouts.suit(number).nonEmpty =>
(
number,
ctx.run(
@@ -835,7 +957,7 @@ class AvatarActor(
.delete
)
)
- case LoadoutType.Vehicle if avatar.loadouts(number + 10).nonEmpty =>
+ case LoadoutType.Vehicle if avatar.loadouts.suit(number + 10).nonEmpty =>
(
number + 10,
ctx.run(
@@ -845,7 +967,7 @@ class AvatarActor(
.delete
)
)
- case LoadoutType.Battleframe if avatar.loadouts(number + 15).nonEmpty =>
+ case LoadoutType.Battleframe if avatar.loadouts.suit(number + 15).nonEmpty =>
(
number + 15,
ctx.run(
@@ -860,7 +982,8 @@ class AvatarActor(
}
result.onComplete {
case Success(_) =>
- avatarCopy(avatar.copy(loadouts = avatar.loadouts.updated(lineNo, None)))
+ val ldouts = avatar.loadouts
+ avatarCopy(avatar.copy(loadouts = ldouts.copy(suit = ldouts.suit.updated(lineNo, None))))
sessionActor ! SessionActor.SendResponse(FavoritesMessage(loadoutType, player.GUID, number, ""))
case Failure(exception) =>
log.error(exception)("db failure (?)")
@@ -872,16 +995,16 @@ class AvatarActor(
Behaviors.same
case InitialRefreshLoadouts() =>
- refreshLoadouts(avatar.loadouts.zipWithIndex)
+ refreshLoadouts(avatar.loadouts.suit.zipWithIndex)
Behaviors.same
case RefreshLoadouts() =>
- refreshLoadouts(avatar.loadouts.zipWithIndex.collect { case out @ (Some(_), _) => out })
+ refreshLoadouts(avatar.loadouts.suit.zipWithIndex.collect { case out @ (Some(_), _) => out })
Behaviors.same
case UpdatePurchaseTime(definition, time) =>
// TODO save to db
- var newTimes = avatar.purchaseTimes
+ var newTimes = avatar.cooldowns.purchase
AvatarActor.resolveSharedPurchaseTimeNames(AvatarActor.resolvePurchaseTimeName(avatar.faction, definition)).foreach {
case (item, name) =>
Avatar.purchaseCooldowns.get(item) match {
@@ -896,19 +1019,20 @@ class AvatarActor(
case _ => ;
}
}
- avatarCopy(avatar.copy(purchaseTimes = newTimes))
+ avatarCopy(avatar.copy(cooldowns = avatar.cooldowns.copy(purchase = newTimes)))
Behaviors.same
case UpdateUseTime(definition, time) =>
if (!Avatar.useCooldowns.contains(definition)) {
log.warn(s"${avatar.name} is updating a use time for item '${definition.Name}' that has no cooldown")
}
- avatarCopy(avatar.copy(useTimes = avatar.useTimes.updated(definition.Name, time)))
+ val cdowns = avatar.cooldowns
+ avatarCopy(avatar.copy(cooldowns = cdowns.copy(use = cdowns.use.updated(definition.Name, time))))
sessionActor ! SessionActor.UseCooldownRenewed(definition, time)
Behaviors.same
case RefreshPurchaseTimes() =>
- refreshPurchaseTimes(avatar.purchaseTimes.keys.toSet)
+ refreshPurchaseTimes(avatar.cooldowns.purchase.keys.toSet)
Behaviors.same
case SetVehicle(vehicle) =>
@@ -1135,13 +1259,14 @@ class AvatarActor(
Behaviors.same
case SetRibbon(ribbon, bar) =>
- val previousRibbonBars = avatar.ribbonBars
+ val decor = avatar.decoration
+ val previousRibbonBars = decor.ribbonBars
val useRibbonBars = Seq(previousRibbonBars.upper, previousRibbonBars.middle, previousRibbonBars.lower)
.indexWhere { _ == ribbon } match {
case -1 => previousRibbonBars
case n => AvatarActor.changeRibbons(previousRibbonBars, MeritCommendation.None, RibbonBarSlot(n))
}
- replaceAvatar(avatar.copy(ribbonBars = AvatarActor.changeRibbons(useRibbonBars, ribbon, bar)))
+ replaceAvatar(avatar.copy(decoration = decor.copy(ribbonBars = AvatarActor.changeRibbons(useRibbonBars, ribbon, bar))))
val player = session.get.player
val zone = player.Zone
zone.AvatarEvents ! AvatarServiceMessage(
@@ -1149,6 +1274,10 @@ class AvatarActor(
AvatarAction.SendResponse(Service.defaultPlayerGUID, DisplayedAwardMessage(player.GUID, ribbon, bar))
)
Behaviors.same
+
+ case MemberListRequest(action, name) =>
+ memberListAction(action, name)
+ Behaviors.same
}
.receiveSignal {
case (_, PostStop) =>
@@ -1191,7 +1320,7 @@ class AvatarActor(
)
.onComplete {
case Success(_) =>
- avatarCopy(avatar.copy(cosmetics = Some(cosmetics)))
+ avatarCopy(avatar.copy(decoration = avatar.decoration.copy(cosmetics = Some(cosmetics))))
session.get.zone.AvatarEvents ! AvatarServiceMessage(
session.get.zone.id,
AvatarAction
@@ -1503,7 +1632,7 @@ class AvatarActor(
def storeLoadout(owner: Player, label: String, line: Int): Future[Loadout] = {
import ctx._
val items: String = {
- val clobber: StringBuilder = new StringBuilder()
+ val clobber: mutable.StringBuilder = new StringBuilder()
//encode holsters
owner
.Holsters()
@@ -1547,7 +1676,7 @@ class AvatarActor(
def storeVehicleLoadout(owner: Player, label: String, line: Int, vehicle: Vehicle): Future[Loadout] = {
import ctx._
val items: String = {
- val clobber: StringBuilder = new StringBuilder()
+ val clobber: mutable.StringBuilder = new StringBuilder()
//encode holsters
vehicle.Weapons
.collect {
@@ -1720,7 +1849,7 @@ class AvatarActor(
}
def refreshLoadout(line: Int): Unit = {
- avatar.loadouts.lift(line) match {
+ avatar.loadouts.suit.lift(line) match {
case Some(Some(loadout: InfantryLoadout)) =>
sessionActor ! SessionActor.SendResponse(
FavoritesMessage.Infantry(
@@ -1768,12 +1897,12 @@ class AvatarActor(
}
}
- def loadLocker(): Future[LockerContainer] = {
+ def loadLocker(charId: Long): Future[LockerContainer] = {
val locker = Avatar.makeLocker()
var notLoaded: Boolean = false
import ctx._
val out = ctx.run(query[persistence.Locker]
- .filter(_.avatarId == lift(avatar.id)))
+ .filter(_.avatarId == lift(charId)))
.map { entry =>
notLoaded = false
entry.foreach { contents => AvatarActor.buildContainedEquipmentFromClob(locker, contents.items, log) }
@@ -1800,6 +1929,50 @@ class AvatarActor(
out
}
+
+
+ def loadFriendList(avatarId: Long): Future[List[AvatarFriend]] = {
+ import ctx._
+ val out: Promise[List[AvatarFriend]] = Promise()
+
+ val queryResult = ctx.run(
+ query[persistence.Friend].filter { _.avatarId == lift(avatarId) }
+ .join(query[persistence.Avatar])
+ .on { case (friend, avatar) => friend.charId == avatar.id }
+ .map { case (_, avatar) => (avatar.id, avatar.name, avatar.factionId) }
+ )
+ queryResult.onComplete {
+ case Success(list) =>
+ out.completeWith(Future(
+ list.map { case (id, name, faction) => AvatarFriend(id, name, PlanetSideEmpire(faction)) }.toList
+ ))
+ case _ =>
+ out.completeWith(Future(List.empty[AvatarFriend]))
+ }
+ out.future
+ }
+
+ def loadIgnoredList(avatarId: Long): Future[List[AvatarIgnored]] = {
+ import ctx._
+ val out: Promise[List[AvatarIgnored]] = Promise()
+
+ val queryResult = ctx.run(
+ query[persistence.Ignored].filter { _.avatarId == lift(avatarId) }
+ .join(query[persistence.Avatar])
+ .on { case (friend, avatar) => friend.charId == avatar.id }
+ .map { case (_, avatar) => (avatar.id, avatar.name) }
+ )
+ queryResult.onComplete {
+ case Success(list) =>
+ out.completeWith(Future(
+ list.map { case (id, name) => AvatarIgnored(id, name) }.toList
+ ))
+ case _ =>
+ out.completeWith(Future(List.empty[AvatarIgnored]))
+ }
+ out.future
+ }
+
def startIfStoppedStaminaRegen(initialDelay: FiniteDuration): Unit = {
if (staminaRegenTimer.isCancelled) {
defaultStaminaRegen(initialDelay)
@@ -1828,7 +2001,7 @@ class AvatarActor(
def refreshPurchaseTimes(keys: Set[String]): Unit = {
var keysToDrop: Seq[String] = Nil
keys.foreach { key =>
- avatar.purchaseTimes.find { case (name, _) => name.equals(key) } match {
+ avatar.cooldowns.purchase.find { case (name, _) => name.equals(key) } match {
case Some((name, purchaseTime)) =>
val secondsSincePurchase = Seconds.secondsBetween(purchaseTime, LocalDateTime.now()).getSeconds
Avatar.purchaseCooldowns.find(_._1.Name == name) match {
@@ -1847,7 +2020,8 @@ class AvatarActor(
}
}
if (keysToDrop.nonEmpty) {
- avatarCopy(avatar.copy(purchaseTimes = avatar.purchaseTimes.removedAll(keysToDrop)))
+ val cdown = avatar.cooldowns
+ avatarCopy(avatar.copy(cooldowns = cdown.copy(purchase = cdown.purchase.removedAll(keysToDrop))))
}
}
@@ -1870,4 +2044,190 @@ class AvatarActor(
case _ => ;
}
}
+
+ /**
+ * Branch based on behavior of the request for the friends list or the ignored people list.
+ * @param action nature of the request
+ * @param name other player's name (can not be our name)
+ */
+ def memberListAction(action: MemberAction.Value, name: String): Unit = {
+ if (!name.equals(avatar.name)) {
+ action match {
+ case MemberAction.InitializeFriendList => memberActionListManagement(action, transformFriendsList)
+ case MemberAction.InitializeIgnoreList => memberActionListManagement(action, transformIgnoredList)
+ case MemberAction.UpdateFriend => memberActionUpdateFriend(name)
+ case MemberAction.AddFriend => getAvatarForFunc(name, memberActionAddFriend)
+ case MemberAction.RemoveFriend => getAvatarForFunc(name, formatForOtherFunc(memberActionRemoveFriend))
+ case MemberAction.AddIgnoredPlayer => getAvatarForFunc(name, memberActionAddIgnored)
+ case MemberAction.RemoveIgnoredPlayer => getAvatarForFunc(name, formatForOtherFunc(memberActionRemoveIgnored))
+ case _ => ;
+ }
+ }
+ }
+
+ /**
+ * Transform the friends list in a list of packet entities.
+ * @return a list of `Friends` suitable for putting into a packet
+ */
+ def transformFriendsList(): List[GameFriend] = {
+ avatar.people.friend.map { f => GameFriend(f.name, f.online)}
+ }
+ /**
+ * Transform the ignored players list in a list of packet entities.
+ * @return a list of `Friends` suitable for putting into a packet
+ */
+ def transformIgnoredList(): List[GameFriend] = {
+ avatar.people.ignored.map { f => GameFriend(f.name, f.online)}
+ }
+ /**
+ * Reload the list of friend players or ignored players for the client.
+ * This does not update any player's online status, but merely reloads current states.
+ * @param action nature of the request
+ * (either `InitializeFriendList` or `InitializeIgnoreList`, hopefully)
+ * @param listFunc transformation function that produces data suitable for a game paket
+ */
+ def memberActionListManagement(action: MemberAction.Value, listFunc: ()=>List[GameFriend]): Unit = {
+ FriendsResponse.packetSequence(action, listFunc()).foreach { msg =>
+ sessionActor ! SessionActor.SendResponse(msg)
+ }
+ }
+
+ /**
+ * Add another player's data to the list of friend players and report back whether or not that player is online.
+ * Update the database appropriately.
+ * @param charId unique account identifier
+ * @param name unique character name
+ * @param faction a faction affiliation
+ */
+ def memberActionAddFriend(charId: Long, name: String, faction: Int): Unit = {
+ val people = avatar.people
+ people.friend.find { _.name.equals(name) } match {
+ case Some(_) => ;
+ case None =>
+ import ctx._
+ ctx.run(query[persistence.Friend]
+ .insert(
+ _.avatarId -> lift(avatar.id.toLong),
+ _.charId -> lift(charId)
+ )
+ )
+ val isOnline = onlineIfNotIgnoredEitherWay(avatar, name)
+ replaceAvatar(avatar.copy(
+ people = people.copy(friend = people.friend :+ AvatarFriend(charId, name, PlanetSideEmpire(faction), isOnline))
+ ))
+ sessionActor ! SessionActor.SendResponse(FriendsResponse(MemberAction.AddFriend, GameFriend(name, isOnline)))
+ }
+ }
+
+ /**
+ * Remove another player's data from the list of friend players.
+ * Update the database appropriately.
+ * @param charId unique account identifier
+ * @param name unique character name
+ */
+ def memberActionRemoveFriend(charId: Long, name: String): Unit = {
+ import ctx._
+ val people = avatar.people
+ people.friend.find { _.name.equals(name) } match {
+ case Some(_) =>
+ replaceAvatar(
+ avatar.copy(people = people.copy(friend = people.friend.filterNot { _.charId == charId }))
+ )
+ case None => ;
+ }
+ ctx.run(query[persistence.Friend]
+ .filter(_.avatarId == lift(avatar.id))
+ .filter(_.charId == lift(charId))
+ .delete
+ )
+ sessionActor ! SessionActor.SendResponse(FriendsResponse(MemberAction.RemoveFriend, GameFriend(name)))
+ }
+
+ /**
+ *
+ * @param name unique character name
+ * @return if the avatar is found, that avatar's unique identifier and the avatar's faction affiliation
+ */
+ def memberActionUpdateFriend(name: String): Option[(Long, PlanetSideEmpire.Value)] = {
+ if (name.nonEmpty) {
+ val people = avatar.people
+ people.friend.find { _.name.equals(name) } match {
+ case Some(otherFriend) =>
+ val (out, online) = LivePlayerList.WorldPopulation({ case (_, a) => a.name.equals(name) }).headOption match {
+ case Some(otherAvatar) =>
+ (
+ Some((otherAvatar.id.toLong, otherAvatar.faction)),
+ onlineIfNotIgnoredEitherWay(otherAvatar, avatar)
+ )
+ case None =>
+ (None, false)
+ }
+ replaceAvatar(avatar.copy(
+ people = people.copy(
+ friend = people.friend.filterNot { _.name.equals(name) } :+ otherFriend.copy(online = online)
+ )
+ ))
+ sessionActor ! SessionActor.SendResponse(FriendsResponse(MemberAction.UpdateFriend, GameFriend(name, online)))
+ out
+ case None =>
+ None
+ }
+ } else {
+ None
+ }
+ }
+
+ /**
+ * Add another player's data to the list of ignored players.
+ * Update the database appropriately.
+ * The change affects not only this player but also the player being ignored
+ * by denying online visibility of the former to the latter.
+ * @param charId unique account identifier
+ * @param name unique character name
+ * @param faction a faction affiliation
+ */
+ def memberActionAddIgnored(charId: Long, name: String, faction: Int): Unit = {
+ val people = avatar.people
+ people.ignored.find { _.name.equals(name) } match {
+ case Some(_) => ;
+ case None =>
+ import ctx._
+ ctx.run(query[persistence.Ignored]
+ .insert(
+ _.avatarId -> lift(avatar.id.toLong),
+ _.charId -> lift(charId)
+ )
+ )
+ replaceAvatar(
+ avatar.copy(people = people.copy(ignored = people.ignored :+ AvatarIgnored(charId, name)))
+ )
+ sessionActor ! SessionActor.UpdateIgnoredPlayers(FriendsResponse(MemberAction.AddIgnoredPlayer, GameFriend(name)))
+ }
+ }
+
+ /**
+ * Remove another player's data from the list of ignored players.
+ * Update the database appropriately.
+ * The change affects not only this player but also the player formerly being ignored
+ * by restoring online visibility of the former to the latter.
+ * @param charId unique account identifier
+ * @param name unique character name
+ */
+ def memberActionRemoveIgnored(charId: Long, name: String): Unit = {
+ import ctx._
+ val people = avatar.people
+ people.ignored.find { _.name.equals(name) } match {
+ case Some(_) =>
+ replaceAvatar(
+ avatar.copy(people = people.copy(ignored = people.ignored.filterNot { _.charId == charId }))
+ )
+ case None => ;
+ }
+ ctx.run(query[persistence.Ignored]
+ .filter(_.avatarId == lift(avatar.id.toLong))
+ .filter(_.charId == lift(charId))
+ .delete
+ )
+ sessionActor ! SessionActor.UpdateIgnoredPlayers(FriendsResponse(MemberAction.RemoveIgnoredPlayer, GameFriend(name)))
+ }
}
diff --git a/src/main/scala/net/psforever/actors/session/ChatActor.scala b/src/main/scala/net/psforever/actors/session/ChatActor.scala
index 8a41c36a..e83a2c22 100644
--- a/src/main/scala/net/psforever/actors/session/ChatActor.scala
+++ b/src/main/scala/net/psforever/actors/session/ChatActor.scala
@@ -25,6 +25,8 @@ 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(
sessionActor: ActorRef[SessionActor.Command],
@@ -123,6 +125,13 @@ class ChatActor(
var chatService: Option[ActorRef[ChatService.Command]] = None
var cluster: Option[ActorRef[InterstellarClusterService.Command]] = None
var silenceTimer: Cancellable = Default.Cancellable
+ /**
+ * when another player is listed as one of our ignored players,
+ * and that other player sends an emote,
+ * that player is assigned a cooldown and only one emote per period will be seen
+ * key - character unique avatar identifier, value - when the current cooldown period will end
+ */
+ var ignoredEmoteCooldown: mutable.LongMap[Long] = mutable.LongMap[Long]()
val chatServiceAdapter: ActorRef[ChatService.MessageResponse] = context.messageAdapter[ChatService.MessageResponse] {
case ChatService.MessageResponse(_session, message, channel) => IncomingMessage(_session, message, channel)
@@ -699,11 +708,21 @@ class ChatActor(
}
case (CMT_TELL, _, _) if !session.player.silenced =>
- chatService ! ChatService.Message(
- session,
- message,
- ChatChannel.Default()
- )
+ if (AvatarActor.onlineIfNotIgnored(message.recipient, session.avatar.name)) {
+ chatService ! ChatService.Message(
+ session,
+ message,
+ ChatChannel.Default()
+ )
+ } else if (AvatarActor.getLiveAvatarForFunc(message.recipient, (_,_,_)=>{}).isEmpty) {
+ sessionActor ! SessionActor.SendResponse(
+ ChatMsg(ChatMessageType.UNK_45, false, "none", "@notell_target", None)
+ )
+ } else {
+ sessionActor ! SessionActor.SendResponse(
+ ChatMsg(ChatMessageType.UNK_45, false, "none", "@notell_ignore", None)
+ )
+ }
case (CMT_BROADCAST, _, _) if !session.player.silenced =>
chatService ! ChatService.Message(
@@ -913,7 +932,7 @@ class ChatActor(
}
case (CMT_TOGGLE_HAT, _, contents) =>
- val cosmetics = session.avatar.cosmetics.getOrElse(Set())
+ val cosmetics = session.avatar.decoration.cosmetics.getOrElse(Set())
val nextCosmetics = contents match {
case "off" =>
cosmetics.diff(Set(Cosmetic.BrimmedCap, Cosmetic.Beret))
@@ -937,7 +956,7 @@ class ChatActor(
)
case (CMT_HIDE_HELMET | CMT_TOGGLE_SHADES | CMT_TOGGLE_EARPIECE, _, contents) =>
- val cosmetics = session.avatar.cosmetics.getOrElse(Set())
+ val cosmetics = session.avatar.decoration.cosmetics.getOrElse(Set())
val cosmetic = message.messageType match {
case CMT_HIDE_HELMET => Cosmetic.NoHelmet
@@ -1055,25 +1074,47 @@ class ChatActor(
case IncomingMessage(fromSession, message, channel) =>
message.messageType match {
- case CMT_TELL | U_CMT_TELLFROM | CMT_BROADCAST | CMT_SQUAD | CMT_PLATOON | CMT_COMMAND | UNK_45 | UNK_71 |
- CMT_NOTE | CMT_GMBROADCAST | CMT_GMBROADCAST_NC | CMT_GMBROADCAST_TR | CMT_GMBROADCAST_VS |
- CMT_GMBROADCASTPOPUP | CMT_GMTELL | U_CMT_GMTELLFROM | UNK_227 | UNK_229 =>
- sessionActor ! SessionActor.SendResponse(message)
+ case CMT_BROADCAST | CMT_SQUAD | CMT_PLATOON | CMT_COMMAND | CMT_NOTE =>
+ if (AvatarActor.onlineIfNotIgnored(session.avatar, message.recipient)) {
+ sessionActor ! SessionActor.SendResponse(message)
+ }
case CMT_OPEN =>
if (
session.zone == fromSession.zone &&
- Vector3.Distance(session.player.Position, fromSession.player.Position) < 25 &&
- session.player.Faction == fromSession.player.Faction
+ Vector3.DistanceSquared(session.player.Position, fromSession.player.Position) < 625 &&
+ session.player.Faction == fromSession.player.Faction &&
+ AvatarActor.onlineIfNotIgnored(session.avatar, message.recipient)
) {
sessionActor ! SessionActor.SendResponse(message)
}
+ case CMT_TELL | U_CMT_TELLFROM |
+ CMT_GMOPEN | CMT_GMBROADCAST | CMT_GMBROADCAST_NC | CMT_GMBROADCAST_TR | CMT_GMBROADCAST_VS |
+ CMT_GMBROADCASTPOPUP | CMT_GMTELL | U_CMT_GMTELLFROM | UNK_45 | UNK_71 | UNK_227 | UNK_229 =>
+ sessionActor ! SessionActor.SendResponse(message)
case CMT_VOICE =>
if (
session.zone == fromSession.zone &&
- Vector3.Distance(session.player.Position, fromSession.player.Position) < 25 ||
+ Vector3.DistanceSquared(session.player.Position, fromSession.player.Position) < 625 ||
message.contents.startsWith("SH") // tactical squad voice macro
) {
- sessionActor ! SessionActor.SendResponse(message)
+ val name = fromSession.avatar.name
+ if (!session.avatar.people.ignored.exists { f => f.name.equals(name) } ||
+ {
+ val id = fromSession.avatar.id.toLong
+ val curr = System.currentTimeMillis()
+ ignoredEmoteCooldown.get(id) match {
+ case None =>
+ ignoredEmoteCooldown.put(id, curr + 15000L)
+ true
+ case Some(time) if time < curr =>
+ ignoredEmoteCooldown.put(id, curr + 15000L)
+ true
+ case _ =>
+ false
+ }}
+ ) {
+ sessionActor ! SessionActor.SendResponse(message)
+ }
}
case CMT_SILENCE =>
val args = message.contents.split(" ")
diff --git a/src/main/scala/net/psforever/actors/session/SessionActor.scala b/src/main/scala/net/psforever/actors/session/SessionActor.scala
index 139fbb9a..9252375e 100644
--- a/src/main/scala/net/psforever/actors/session/SessionActor.scala
+++ b/src/main/scala/net/psforever/actors/session/SessionActor.scala
@@ -124,6 +124,8 @@ object SessionActor {
final case class UseCooldownRenewed(definition: BasicDefinition, time: LocalDateTime) extends Command
+ final case class UpdateIgnoredPlayers(msg: FriendsResponse) extends Command
+
/**
* The message that progresses some form of user-driven activity with a certain eventual outcome
* and potential feedback per cycle.
@@ -681,6 +683,12 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
case AvatarServiceResponse(toChannel, guid, reply) =>
HandleAvatarServiceResponse(toChannel, guid, reply)
+ case UpdateIgnoredPlayers(msg) =>
+ sendResponse(msg)
+ msg.friends.foreach { f =>
+ galaxyService ! GalaxyServiceMessage(GalaxyAction.LogStatusChange(f.name))
+ }
+
case SendResponse(packet) =>
sendResponse(packet)
@@ -829,6 +837,7 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
hot_spot_info.map { spot => PacketHotSpotInfo(spot.DisplayLocation.x, spot.DisplayLocation.y, 40) }
)
)
+
case GalaxyResponse.MapUpdate(msg) =>
sendResponse(msg)
@@ -904,6 +913,11 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
val popVS = zone.Players.count(_.faction == PlanetSideEmpire.VS)
sendResponse(ZonePopulationUpdateMessage(zone.Number, 414, 138, popTR, 138, popNC, 138, popVS, 138, popBO))
+ case GalaxyResponse.LogStatusChange(name) =>
+ if (avatar.people.friend.exists { _.name.equals(name) }) {
+ avatarActor ! AvatarActor.MemberListRequest(MemberAction.UpdateFriend, name)
+ }
+
case GalaxyResponse.SendResponse(msg) =>
sendResponse(msg)
}
@@ -1380,6 +1394,7 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
context.self
)
LivePlayerList.Add(avatar.id, avatar)
+ galaxyService.tell(GalaxyServiceMessage(GalaxyAction.LogStatusChange(avatar.name)), context.parent)
//PropertyOverrideMessage
implicit val timeout = Timeout(1 seconds)
@@ -1391,8 +1406,21 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
sendResponse(PlanetsideAttributeMessage(PlanetSideGUID(0), 112, 0)) // disable festive backpacks
sendResponse(ReplicationStreamMessage(5, Some(6), Vector.empty)) //clear squad list
- sendResponse(FriendsResponse(FriendAction.InitializeFriendList, 0, true, true, Nil))
- sendResponse(FriendsResponse(FriendAction.InitializeIgnoreList, 0, true, true, Nil))
+ (
+ //friend list (some might be online)
+ FriendsResponse.packetSequence(
+ MemberAction.InitializeFriendList,
+ avatar.people.friend
+ .map { f =>
+ game.Friend(f.name, AvatarActor.onlineIfNotIgnoredEitherWay(avatar, f.name))
+ }
+ ) ++
+ //ignored list (no one ever online)
+ FriendsResponse.packetSequence(
+ MemberAction.InitializeIgnoreList,
+ avatar.people.ignored.map { f => game.Friend(f.name) }
+ )
+ ).foreach { sendResponse }
//the following subscriptions last until character switch/logout
galaxyService ! Service.Join("galaxy") //for galaxy-wide messages
galaxyService ! Service.Join(s"${avatar.faction}") //for hotspots, etc.
@@ -3605,7 +3633,7 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
sendResponse(ReplicationStreamMessage(5, Some(6), Vector.empty)) //clear squad list
sendResponse(SquadDefinitionActionMessage(PlanetSideGUID(0), 0, SquadAction.Unknown(6)))
//only need to load these once - they persist between zone transfers and respawns
- avatar.squadLoadouts.zipWithIndex.foreach {
+ avatar.loadouts.squad.zipWithIndex.foreach {
case (Some(loadout), index) =>
sendResponse(
SquadDefinitionActionMessage(PlanetSideGUID(0), index, SquadAction.ListSquadFavorite(loadout.task))
@@ -6260,7 +6288,8 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
case msg @ CreateShortcutMessage(player_guid, slot, unk, add, shortcut) => ;
- case msg @ FriendsRequest(action, friend) => ;
+ case FriendsRequest(action, name) =>
+ avatarActor ! AvatarActor.MemberListRequest(action, name)
case msg @ HitHint(source_guid, player_guid) => ; //HitHint is manually distributed for proper operation
@@ -6858,7 +6887,7 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
}
val stowNewFunc: Equipment => TaskBundle = PutNewEquipmentInInventoryOrDrop(obj)
val stowFunc: Equipment => Future[Any] = PutEquipmentInInventoryOrDrop(obj)
-
+
xs.foreach(item => {
obj.Inventory -= item.start
sendResponse(ObjectDeleteMessage(item.obj.GUID, 0))
diff --git a/src/main/scala/net/psforever/objects/avatar/Acquaintance.scala b/src/main/scala/net/psforever/objects/avatar/Acquaintance.scala
new file mode 100644
index 00000000..ea1726bd
--- /dev/null
+++ b/src/main/scala/net/psforever/objects/avatar/Acquaintance.scala
@@ -0,0 +1,19 @@
+// Copyright (c) 2022 PSForever
+package net.psforever.objects.avatar
+
+import net.psforever.types.PlanetSideEmpire
+
+case class Friend(
+ charId: Long = 0,
+ name: String = "",
+ faction: PlanetSideEmpire.Value,
+ online: Boolean = false
+ )
+
+case class Ignored(
+ charId: Long = 0,
+ name: String = "",
+ online: Boolean = false
+ ) {
+ val faction: PlanetSideEmpire.Value = PlanetSideEmpire.NEUTRAL
+}
diff --git a/src/main/scala/net/psforever/objects/avatar/Avatar.scala b/src/main/scala/net/psforever/objects/avatar/Avatar.scala
index d488dd68..4b3e47d5 100644
--- a/src/main/scala/net/psforever/objects/avatar/Avatar.scala
+++ b/src/main/scala/net/psforever/objects/avatar/Avatar.scala
@@ -11,7 +11,6 @@ import net.psforever.packet.game.objectcreate.RibbonBars
import net.psforever.types._
import org.joda.time.{Duration, LocalDateTime, Seconds}
-import scala.collection.immutable.Seq
import scala.concurrent.duration._
object Avatar {
@@ -83,6 +82,33 @@ object Avatar {
}
}
+case class Cooldowns(
+ /** Timestamps of when a vehicle or equipment was last purchased */
+ purchase: Map[String, LocalDateTime] = Map(),
+ /** Timestamps of when a vehicle or equipment was last purchased */
+ use: Map[String, LocalDateTime] = Map()
+ )
+
+case class Loadouts(
+ suit: Seq[Option[Loadout]] = Seq.fill(20)(None),
+ squad: Seq[Option[SquadLoadout]] = Seq.fill(10)(None)
+ )
+
+case class ProgressDecoration(
+ cosmetics: Option[Set[Cosmetic]] = None,
+ ribbonBars: RibbonBars = RibbonBars(),
+ firstTimeEvents: Set[String] =
+ FirstTimeEvents.Maps ++ FirstTimeEvents.Monoliths ++
+ FirstTimeEvents.Standard.All ++ FirstTimeEvents.Cavern.All ++
+ FirstTimeEvents.TR.All ++ FirstTimeEvents.NC.All ++ FirstTimeEvents.VS.All ++
+ FirstTimeEvents.Generic
+ )
+
+case class MemberLists(
+ friend: List[Friend] = List[Friend](),
+ ignored: List[Ignored] = List[Ignored]()
+ )
+
case class Avatar(
/** unique identifier corresponding to a database table row index */
id: Int,
@@ -95,25 +121,16 @@ case class Avatar(
cep: Long = 0,
stamina: Int = 100,
fatigued: Boolean = false,
- cosmetics: Option[Set[Cosmetic]] = None,
- ribbonBars: RibbonBars = RibbonBars(),
certifications: Set[Certification] = Set(),
- loadouts: Seq[Option[Loadout]] = Seq.fill(20)(None),
- squadLoadouts: Seq[Option[SquadLoadout]] = Seq.fill(10)(None),
implants: Seq[Option[Implant]] = Seq(None, None, None),
locker: LockerContainer = Avatar.makeLocker(),
- deployables: DeployableToolbox = new DeployableToolbox(), // TODO var bad
+ deployables: DeployableToolbox = new DeployableToolbox(),
lookingForSquad: Boolean = false,
var vehicle: Option[PlanetSideGUID] = None, // TODO var bad
- firstTimeEvents: Set[String] =
- FirstTimeEvents.Maps ++ FirstTimeEvents.Monoliths ++
- FirstTimeEvents.Standard.All ++ FirstTimeEvents.Cavern.All ++
- FirstTimeEvents.TR.All ++ FirstTimeEvents.NC.All ++ FirstTimeEvents.VS.All ++
- FirstTimeEvents.Generic,
- /** Timestamps of when a vehicle or equipment was last purchased */
- purchaseTimes: Map[String, LocalDateTime] = Map(),
- /** Timestamps of when a vehicle or equipment was last purchased */
- useTimes: Map[String, LocalDateTime] = Map()
+ decoration: ProgressDecoration = ProgressDecoration(),
+ loadouts: Loadouts = Loadouts(),
+ cooldowns: Cooldowns = Cooldowns(),
+ people: MemberLists = MemberLists()
) {
assert(bep >= 0)
assert(cep >= 0)
@@ -141,12 +158,12 @@ case class Avatar(
/** Returns the remaining purchase cooldown or None if an object is not on cooldown */
def purchaseCooldown(definition: BasicDefinition): Option[Duration] = {
- cooldown(purchaseTimes, Avatar.purchaseCooldowns, definition)
+ cooldown(cooldowns.purchase, Avatar.purchaseCooldowns, definition)
}
/** Returns the remaining use cooldown or None if an object is not on cooldown */
def useCooldown(definition: BasicDefinition): Option[Duration] = {
- cooldown(useTimes, Avatar.useCooldowns, definition)
+ cooldown(cooldowns.use, Avatar.useCooldowns, definition)
}
def fifthSlot(): EquipmentSlot = {
diff --git a/src/main/scala/net/psforever/objects/definition/converter/AvatarConverter.scala b/src/main/scala/net/psforever/objects/definition/converter/AvatarConverter.scala
index 2a95d425..f8918450 100644
--- a/src/main/scala/net/psforever/objects/definition/converter/AvatarConverter.scala
+++ b/src/main/scala/net/psforever/objects/definition/converter/AvatarConverter.scala
@@ -105,7 +105,7 @@ object AvatarConverter {
unk7 = false,
on_zipline = None
)
- CharacterAppearanceData(aa, ab, obj.avatar.ribbonBars)
+ CharacterAppearanceData(aa, ab, obj.avatar.decoration.ribbonBars)
}
def MakeCharacterData(obj: Player): (Boolean, Boolean) => CharacterData = {
@@ -121,7 +121,7 @@ object AvatarConverter {
0,
obj.avatar.cr.value,
obj.avatar.implants.flatten.filter(_.active).flatMap(_.definition.implantType.effect).toList,
- obj.avatar.cosmetics
+ obj.avatar.decoration.cosmetics
)
}
@@ -153,7 +153,7 @@ object AvatarConverter {
obj.avatar.implants.flatten.map(_.toEntry).toList,
Nil,
Nil,
- obj.avatar.firstTimeEvents.toList,
+ obj.avatar.decoration.firstTimeEvents.toList,
tutorials = List.empty[String], //TODO tutorial list
0L,
0L,
@@ -164,7 +164,7 @@ object AvatarConverter {
Nil,
Nil,
unkC = false,
- obj.avatar.cosmetics
+ obj.avatar.decoration.cosmetics
)
pad_length: Option[Int] => DetailedCharacterData(ba, bb(obj.avatar.bep, pad_length))(pad_length)
}
diff --git a/src/main/scala/net/psforever/objects/definition/converter/CharacterSelectConverter.scala b/src/main/scala/net/psforever/objects/definition/converter/CharacterSelectConverter.scala
index 9133065e..2471b627 100644
--- a/src/main/scala/net/psforever/objects/definition/converter/CharacterSelectConverter.scala
+++ b/src/main/scala/net/psforever/objects/definition/converter/CharacterSelectConverter.scala
@@ -79,7 +79,7 @@ class CharacterSelectConverter extends AvatarConverter {
unk7 = false,
on_zipline = None
)
- CharacterAppearanceData(aa, ab, obj.avatar.ribbonBars)
+ CharacterAppearanceData(aa, ab, obj.avatar.decoration.ribbonBars)
}
private def MakeDetailedCharacterData(obj: Player): Option[Int] => DetailedCharacterData = {
@@ -123,7 +123,7 @@ class CharacterSelectConverter extends AvatarConverter {
Nil,
Nil,
unkC = false,
- obj.avatar.cosmetics
+ obj.avatar.decoration.cosmetics
)
pad_length: Option[Int] => DetailedCharacterData(ba, bb(obj.avatar.bep, pad_length))(pad_length)
}
diff --git a/src/main/scala/net/psforever/objects/definition/converter/CorpseConverter.scala b/src/main/scala/net/psforever/objects/definition/converter/CorpseConverter.scala
index 3c985ac6..357ef2ea 100644
--- a/src/main/scala/net/psforever/objects/definition/converter/CorpseConverter.scala
+++ b/src/main/scala/net/psforever/objects/definition/converter/CorpseConverter.scala
@@ -70,7 +70,7 @@ class CorpseConverter extends AvatarConverter {
unk7 = false,
on_zipline = None
)
- CharacterAppearanceData(aa, ab, obj.avatar.ribbonBars)
+ CharacterAppearanceData(aa, ab, obj.avatar.decoration.ribbonBars)
}
private def MakeDetailedCharacterData(obj: Player): Option[Int] => DetailedCharacterData = {
diff --git a/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/BattleframeSpawnLoadoutPage.scala b/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/BattleframeSpawnLoadoutPage.scala
index fb671696..02b406c4 100644
--- a/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/BattleframeSpawnLoadoutPage.scala
+++ b/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/BattleframeSpawnLoadoutPage.scala
@@ -20,8 +20,9 @@ import net.psforever.packet.game.ItemTransactionMessage
*/
final case class BattleframeSpawnLoadoutPage(vehicles: Map[String, () => Vehicle]) extends LoadoutTab {
override def Buy(player: Player, msg: ItemTransactionMessage): Terminal.Exchange = {
- player.avatar.loadouts(msg.unk1 + 15) match {
- case Some(loadout: VehicleLoadout) if !Exclude.contains(loadout.vehicle_definition) =>
+ player.avatar.loadouts.suit(msg.unk1 + 15) match {
+ case Some(loadout: VehicleLoadout)
+ if !Exclude.exists(_.checkRule(player, msg, loadout.vehicle_definition)) =>
vehicles.get(loadout.vehicle_definition.Name) match {
case Some(vehicle) =>
val weapons = loadout.visible_slots.map(entry => {
diff --git a/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/InfantryLoadoutPage.scala b/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/InfantryLoadoutPage.scala
index 805c04f4..d84c6a4d 100644
--- a/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/InfantryLoadoutPage.scala
+++ b/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/InfantryLoadoutPage.scala
@@ -27,7 +27,7 @@ import net.psforever.packet.game.ItemTransactionMessage
*/
final case class InfantryLoadoutPage() extends LoadoutTab {
override def Buy(player: Player, msg: ItemTransactionMessage): Terminal.Exchange = {
- player.avatar.loadouts(msg.unk1) match {
+ player.avatar.loadouts.suit(msg.unk1) match {
case Some(loadout: InfantryLoadout)
if !Exclude.exists(_.checkRule(player, msg, loadout.exosuit)) &&
!Exclude.exists(_.checkRule(player, msg, (loadout.exosuit, loadout.subtype))) =>
diff --git a/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/VehicleLoadoutPage.scala b/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/VehicleLoadoutPage.scala
index ff7a6aca..e0a6ad44 100644
--- a/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/VehicleLoadoutPage.scala
+++ b/src/main/scala/net/psforever/objects/serverobject/terminals/tabs/VehicleLoadoutPage.scala
@@ -23,7 +23,7 @@ import net.psforever.packet.game.ItemTransactionMessage
*/
final case class VehicleLoadoutPage(lineOffset: Int) extends LoadoutTab {
override def Buy(player: Player, msg: ItemTransactionMessage): Terminal.Exchange = {
- player.avatar.loadouts(msg.unk1 + lineOffset) match {
+ player.avatar.loadouts.suit(msg.unk1 + lineOffset) match {
case Some(loadout: VehicleLoadout) =>
val weapons = loadout.visible_slots
.map(entry => {
diff --git a/src/main/scala/net/psforever/packet/game/FriendsRequest.scala b/src/main/scala/net/psforever/packet/game/FriendsRequest.scala
index dc6d5d9b..89f3d9ac 100644
--- a/src/main/scala/net/psforever/packet/game/FriendsRequest.scala
+++ b/src/main/scala/net/psforever/packet/game/FriendsRequest.scala
@@ -2,6 +2,7 @@
package net.psforever.packet.game
import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket}
+import net.psforever.types.MemberAction
import scodec.Codec
import scodec.codecs._
@@ -15,23 +16,12 @@ import scodec.codecs._
* No name will be appended or removed from any list until the response to this packet is received.
*
* Actions that involve the "remove" functionality will locate the entered name in the local list before dispatching this packet.
- * A complaint will be logged to the event window if the name is not found.
- *
- * Actions:
- * 0 - display friends list
- * 1 - add player to friends list
- * 2 - remove player from friends list
- * 4 - display ignored player list
- * 5 - add player to ignored player list
- * 6 - remove player from ignored player list
- *
- * Exploration:
- * Are action = 3 and action = 7 supposed to do anything?
+ * A complaint will be logged to the event window if the name is not found.
* @param action the purpose of this packet
* @param friend the player name that was entered;
* blank in certain situations
*/
-final case class FriendsRequest(action: Int, friend: String) extends PlanetSideGamePacket {
+final case class FriendsRequest(action: MemberAction.Value, friend: String) extends PlanetSideGamePacket {
type Packet = FriendsRequest
def opcode = GamePacketOpcode.FriendsRequest
def encode = FriendsRequest.encode(this)
@@ -39,7 +29,7 @@ final case class FriendsRequest(action: Int, friend: String) extends PlanetSideG
object FriendsRequest extends Marshallable[FriendsRequest] {
implicit val codec: Codec[FriendsRequest] = (
- ("action" | uintL(3)) ::
+ ("action" | MemberAction.codec) ::
("friend" | PacketHelpers.encodedWideStringAligned(5))
).as[FriendsRequest]
}
diff --git a/src/main/scala/net/psforever/packet/game/FriendsResponse.scala b/src/main/scala/net/psforever/packet/game/FriendsResponse.scala
index 75c7dfdb..7df03d83 100644
--- a/src/main/scala/net/psforever/packet/game/FriendsResponse.scala
+++ b/src/main/scala/net/psforever/packet/game/FriendsResponse.scala
@@ -3,20 +3,12 @@ package net.psforever.packet.game
import net.psforever.packet.GamePacketOpcode.Type
import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket}
+import net.psforever.types.MemberAction
import scodec.bits.BitVector
import scodec.{Attempt, Codec}
import scodec.codecs._
import shapeless.{::, HNil}
-object FriendAction extends Enumeration {
- type Type = Value
-
- val InitializeFriendList, AddFriend, RemoveFriend, UpdateFriend, InitializeIgnoreList, AddIgnoredPlayer,
- RemoveIgnoredPlayer = Value
-
- implicit val codec: Codec[FriendAction.Value] = PacketHelpers.createEnumerationCodec(this, uint(bits = 3))
-}
-
/**
* An entry in the list of players known to and tracked by this player.
* They're called "friends" even though they can be used for a list of ignored players as well.
@@ -30,17 +22,7 @@ final case class Friend(name: String, online: Boolean = false)
*
* Friends can be remembered and their current playing status can be reported.
* Ignored players will have their comments stifled in the given player's chat window.
- * This does not handle outfit member lists.
- *
- * Actions:
- * 0 - initialize friends list (no logging)
- * 1 - add entry to friends list
- * 2 - remove entry from friends list
- * 3 - update status of player in friends list;
- * if player is not listed, he is not added
- * 4 - initialize ignored players list (no logging)
- * 5 - add entry to ignored players list
- * 6 - remove entry from ignored players list
+ * This does not handle outfit member lists.
* @param action the purpose of the entry(s) in this packet
* @param unk1 na;
* always 0?
@@ -49,11 +31,11 @@ final case class Friend(name: String, online: Boolean = false)
* @param friends a list of `Friend`s
*/
final case class FriendsResponse(
- action: FriendAction.Value,
- unk1: Int,
- first_entry: Boolean,
- last_entry: Boolean,
- friends: List[Friend] = Nil
+ action: MemberAction.Value,
+ unk1: Int,
+ first_entry: Boolean,
+ last_entry: Boolean,
+ friends: List[Friend]
) extends PlanetSideGamePacket {
type Packet = FriendsResponse
@@ -64,7 +46,7 @@ final case class FriendsResponse(
object Friend extends Marshallable[Friend] {
implicit val codec: Codec[Friend] = (
- ("name" | PacketHelpers.encodedWideStringAligned(3)) ::
+ ("name" | PacketHelpers.encodedWideStringAligned(adjustment = 3)) ::
("online" | bool)
).as[Friend]
@@ -73,19 +55,47 @@ object Friend extends Marshallable[Friend] {
* Initial byte-alignment creates padding differences which requires a second `Codec`.
*/
implicit val codec_list: Codec[Friend] = (
- ("name" | PacketHelpers.encodedWideStringAligned(7)) ::
+ ("name" | PacketHelpers.encodedWideStringAligned(adjustment = 7)) ::
("online" | bool)
).as[Friend]
}
object FriendsResponse extends Marshallable[FriendsResponse] {
+ def apply(action: MemberAction.Value, friend: Friend): FriendsResponse = {
+ FriendsResponse(action, unk1=0, first_entry=true, last_entry=true, List(friend))
+ }
+
+ /**
+ * Take a list of members and construct appropriate packets by which they can be dispatched to the client.
+ * Attention needs to be paid to the number of entries in a single packet,
+ * and where the produced packets begin and end.
+ * @param action the purpose of the entry(s) in this packet
+ * @param friends a list of `Friend`s
+ * @return a list of `FriendResponse` packets
+ */
+ def packetSequence(action: MemberAction.Value, friends: List[Friend]): List[FriendsResponse] = {
+ val lists = friends.grouped(15)
+ val size = lists.size
+ if (size <= 1) {
+ List(FriendsResponse(action, unk1=0, first_entry=true, last_entry=true, friends))
+ } else {
+ val size1 = size - 1
+ val first = lists.take(1)
+ val rest = lists.slice(1, size1)
+ val last = lists.drop(size1)
+ List(FriendsResponse(action, unk1=0, first_entry=true, last_entry=false, first.next())) ++
+ rest.map { FriendsResponse(action, unk1=0, first_entry=false, last_entry=false, _)} ++
+ List(FriendsResponse(action, unk1=0, first_entry=false, last_entry=true, last.next()))
+ }
+ }
+
implicit val codec: Codec[FriendsResponse] = (
- ("action" | FriendAction.codec) ::
+ ("action" | MemberAction.codec) ::
("unk1" | uint4L) ::
("first_entry" | bool) ::
("last_entry" | bool) ::
(("number_of_friends" | uint4L) >>:~ { len =>
- conditional(len > 0, "friend" | Friend.codec) ::
+ conditional(len > 0, codec = "friend" | Friend.codec) ::
("friends" | PacketHelpers.listOfNSized(len - 1, Friend.codec_list))
})
).xmap[FriendsResponse](
diff --git a/src/main/scala/net/psforever/persistence/Acquaintance.scala b/src/main/scala/net/psforever/persistence/Acquaintance.scala
new file mode 100644
index 00000000..3a17784f
--- /dev/null
+++ b/src/main/scala/net/psforever/persistence/Acquaintance.scala
@@ -0,0 +1,6 @@
+// Copyright (c) 2022 PSForever
+package net.psforever.persistence
+
+case class Friend(id: Int, avatarId: Long, charId: Long)
+
+case class Ignored(id: Int, avatarId: Long, charId: Long)
diff --git a/src/main/scala/net/psforever/persistence/Avatar.scala b/src/main/scala/net/psforever/persistence/Avatar.scala
index 24dae5f1..6a489076 100644
--- a/src/main/scala/net/psforever/persistence/Avatar.scala
+++ b/src/main/scala/net/psforever/persistence/Avatar.scala
@@ -1,7 +1,7 @@
package net.psforever.persistence
import net.psforever.objects.avatar
-import net.psforever.objects.avatar.Cosmetic
+import net.psforever.objects.avatar.{Cosmetic, ProgressDecoration}
import org.joda.time.LocalDateTime
import net.psforever.types.{CharacterSex, CharacterVoice, PlanetSideEmpire}
@@ -32,6 +32,6 @@ case class Avatar(
CharacterVoice(voiceId),
bep,
cep,
- cosmetics = cosmetics.map(c => Cosmetic.valuesFromObjectCreateValue(c))
+ decoration = ProgressDecoration(cosmetics = cosmetics.map(c => Cosmetic.valuesFromObjectCreateValue(c)))
)
}
diff --git a/src/main/scala/net/psforever/services/account/AccountPersistenceService.scala b/src/main/scala/net/psforever/services/account/AccountPersistenceService.scala
index 76993e10..4ef85183 100644
--- a/src/main/scala/net/psforever/services/account/AccountPersistenceService.scala
+++ b/src/main/scala/net/psforever/services/account/AccountPersistenceService.scala
@@ -14,6 +14,7 @@ import net.psforever.objects.zones.Zone
import net.psforever.types.Vector3
import net.psforever.services.{Service, ServiceManager}
import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage}
+import net.psforever.services.galaxy.{GalaxyAction, GalaxyServiceMessage}
/**
* A global service that manages user behavior as divided into the following three categories:
@@ -48,9 +49,11 @@ class AccountPersistenceService extends Actor {
/** squad service event hook */
var squad: ActorRef = ActorRef.noSender
+ /** galaxy service event hook */
+ var galaxy: ActorRef = ActorRef.noSender
/** log, for trace and warnings only */
- val log = org.log4s.getLogger
+ private val log = org.log4s.getLogger
/**
* Retrieve the required system event service hooks.
@@ -59,6 +62,7 @@ class AccountPersistenceService extends Actor {
*/
override def preStart(): Unit = {
ServiceManager.serviceManager ! ServiceManager.Lookup("squad")
+ ServiceManager.serviceManager ! ServiceManager.Lookup("galaxy")
}
override def postStop(): Unit = {
@@ -127,15 +131,24 @@ class AccountPersistenceService extends Actor {
val Setup: Receive = {
case ServiceManager.LookupResult("squad", endpoint) =>
squad = endpoint
- if (squad != ActorRef.noSender) {
- log.trace("Service hooks obtained. Continuing with standard operation.")
- context.become(Started)
- }
+ log.trace("Service hooks obtained. Attempting standard operation.")
+ switchToStarted()
+
+ case ServiceManager.LookupResult("galaxy", endpoint) =>
+ galaxy = endpoint
+ log.trace("Service hooks obtained. Attempting standard operation.")
+ switchToStarted()
case msg =>
log.warn(s"not yet started; received a $msg that will go unhandled")
}
+ def switchToStarted(): Unit = {
+ if (squad != ActorRef.noSender && galaxy != ActorRef.noSender) {
+ context.become(Started)
+ }
+ }
+
/**
* Enqueue a new persistency monitor object for this player.
* @param name the unique name of the player
@@ -143,7 +156,7 @@ class AccountPersistenceService extends Actor {
*/
def CreateNewPlayerToken(name: String): ActorRef = {
val ref =
- context.actorOf(Props(classOf[PersistenceMonitor], name, squad), s"${NextPlayerIndex(name)}_${name.hashCode()}")
+ context.actorOf(Props(classOf[PersistenceMonitor], name, squad, galaxy), s"${NextPlayerIndex(name)}_${name.hashCode()}")
accounts += name -> ref
ref
}
@@ -219,8 +232,11 @@ object AccountPersistenceService {
* @param name the unique name of the player
* @param squadService a hook into the `SquadService` event system
*/
-class PersistenceMonitor(name: String, squadService: ActorRef) extends Actor {
-
+class PersistenceMonitor(
+ name: String,
+ squadService: ActorRef,
+ galaxyService: ActorRef
+ ) extends Actor {
/** the last-reported zone of this player */
var inZone: Zone = Zone.Nowhere
@@ -242,7 +258,7 @@ class PersistenceMonitor(name: String, squadService: ActorRef) extends Actor {
var timer: Cancellable = Default.Cancellable
/** the sparingly-used log */
- val log = org.log4s.getLogger
+ private val log = org.log4s.getLogger
/**
* Perform logout operations before the persistence monitor finally stops.
@@ -407,6 +423,7 @@ class PersistenceMonitor(name: String, squadService: ActorRef) extends Actor {
def AvatarLogout(avatar: Avatar): Unit = {
LivePlayerList.Remove(avatar.id)
squadService.tell(Service.Leave(Some(avatar.id.toString)), context.parent)
+ galaxyService.tell(GalaxyServiceMessage(GalaxyAction.LogStatusChange(avatar.name)), context.parent)
Deployables.Disown(inZone, avatar, context.parent)
inZone.Population.tell(Zone.Population.Leave(avatar), context.parent)
TaskWorkflow.execute(GUIDTask.unregisterObject(inZone.GUID, avatar.locker))
diff --git a/src/main/scala/net/psforever/services/galaxy/GalaxyService.scala b/src/main/scala/net/psforever/services/galaxy/GalaxyService.scala
index 7d269356..a526db02 100644
--- a/src/main/scala/net/psforever/services/galaxy/GalaxyService.scala
+++ b/src/main/scala/net/psforever/services/galaxy/GalaxyService.scala
@@ -78,9 +78,20 @@ class GalaxyService extends Actor {
)
)
+ case GalaxyAction.LogStatusChange(name) =>
+ GalaxyEvents.publish(
+ GalaxyServiceResponse(
+ s"/Galaxy",
+ GalaxyResponse.LogStatusChange(name)
+ )
+ )
+
case GalaxyAction.SendResponse(msg) =>
GalaxyEvents.publish(
- GalaxyServiceResponse(s"/Galaxy", GalaxyResponse.SendResponse(msg))
+ GalaxyServiceResponse(
+ s"/Galaxy",
+ GalaxyResponse.SendResponse(msg)
+ )
)
case _ => ;
}
diff --git a/src/main/scala/net/psforever/services/galaxy/GalaxyServiceMessage.scala b/src/main/scala/net/psforever/services/galaxy/GalaxyServiceMessage.scala
index 9deae81f..dcdaebfc 100644
--- a/src/main/scala/net/psforever/services/galaxy/GalaxyServiceMessage.scala
+++ b/src/main/scala/net/psforever/services/galaxy/GalaxyServiceMessage.scala
@@ -39,5 +39,7 @@ object GalaxyAction {
final case class UnlockedZoneUpdate(zone: Zone) extends Action
+ final case class LogStatusChange(name: String) extends Action
+
final case class SendResponse(msg: PlanetSideGamePacket) extends Action
}
diff --git a/src/main/scala/net/psforever/services/galaxy/GalaxyServiceResponse.scala b/src/main/scala/net/psforever/services/galaxy/GalaxyServiceResponse.scala
index 76de86b2..dcaf9381 100644
--- a/src/main/scala/net/psforever/services/galaxy/GalaxyServiceResponse.scala
+++ b/src/main/scala/net/psforever/services/galaxy/GalaxyServiceResponse.scala
@@ -38,5 +38,7 @@ object GalaxyResponse {
final case class UnlockedZoneUpdate(zone: Zone) extends Response
+ final case class LogStatusChange(name: String) extends Response
+
final case class SendResponse(msg: PlanetSideGamePacket) extends Response
}
diff --git a/src/main/scala/net/psforever/types/MemberAction.scala b/src/main/scala/net/psforever/types/MemberAction.scala
new file mode 100644
index 00000000..41dc28ed
--- /dev/null
+++ b/src/main/scala/net/psforever/types/MemberAction.scala
@@ -0,0 +1,15 @@
+// Copyright (c) 2022 PSForever
+package net.psforever.types
+
+import net.psforever.packet.PacketHelpers
+import scodec.Codec
+import scodec.codecs._
+
+object MemberAction extends Enumeration {
+ type Type = Value
+
+ val InitializeFriendList, AddFriend, RemoveFriend, UpdateFriend, InitializeIgnoreList, AddIgnoredPlayer,
+ RemoveIgnoredPlayer = Value
+
+ implicit val codec: Codec[MemberAction.Value] = PacketHelpers.createEnumerationCodec(this, uint(bits = 3))
+}
\ No newline at end of file
diff --git a/src/test/scala/game/FriendsRequestTest.scala b/src/test/scala/game/FriendsRequestTest.scala
index 3c058e42..a37fdb5b 100644
--- a/src/test/scala/game/FriendsRequestTest.scala
+++ b/src/test/scala/game/FriendsRequestTest.scala
@@ -4,6 +4,7 @@ package game
import org.specs2.mutable._
import net.psforever.packet._
import net.psforever.packet.game._
+import net.psforever.types.MemberAction
import scodec.bits._
class FriendsRequestTest extends Specification {
@@ -12,7 +13,7 @@ class FriendsRequestTest extends Specification {
"decode" in {
PacketCoding.decodePacket(string).require match {
case FriendsRequest(action, friend) =>
- action mustEqual 1
+ action mustEqual MemberAction.AddFriend
friend.length mustEqual 5
friend mustEqual "FJHNC"
case _ =>
@@ -21,7 +22,7 @@ class FriendsRequestTest extends Specification {
}
"encode" in {
- val msg = FriendsRequest(1, "FJHNC")
+ val msg = FriendsRequest(MemberAction.AddFriend, "FJHNC")
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual string
diff --git a/src/test/scala/game/FriendsResponseTest.scala b/src/test/scala/game/FriendsResponseTest.scala
index 8e185893..18ddf960 100644
--- a/src/test/scala/game/FriendsResponseTest.scala
+++ b/src/test/scala/game/FriendsResponseTest.scala
@@ -4,6 +4,7 @@ package game
import org.specs2.mutable._
import net.psforever.packet._
import net.psforever.packet.game._
+import net.psforever.types.MemberAction
import scodec.bits._
class FriendsResponseTest extends Specification {
@@ -15,7 +16,7 @@ class FriendsResponseTest extends Specification {
"decode (one friend)" in {
PacketCoding.decodePacket(stringOneFriend).require match {
case FriendsResponse(action, unk2, unk3, unk4, list) =>
- action mustEqual FriendAction.UpdateFriend
+ action mustEqual MemberAction.UpdateFriend
unk2 mustEqual 0
unk3 mustEqual true
unk4 mustEqual true
@@ -30,7 +31,7 @@ class FriendsResponseTest extends Specification {
"decode (multiple friends)" in {
PacketCoding.decodePacket(stringManyFriends).require match {
case FriendsResponse(action, unk2, unk3, unk4, list) =>
- action mustEqual FriendAction.InitializeFriendList
+ action mustEqual MemberAction.InitializeFriendList
unk2 mustEqual 0
unk3 mustEqual true
unk4 mustEqual true
@@ -53,7 +54,7 @@ class FriendsResponseTest extends Specification {
"decode (short)" in {
PacketCoding.decodePacket(stringShort).require match {
case FriendsResponse(action, unk2, unk3, unk4, list) =>
- action mustEqual FriendAction.InitializeIgnoreList
+ action mustEqual MemberAction.InitializeIgnoreList
unk2 mustEqual 0
unk3 mustEqual true
unk4 mustEqual true
@@ -65,7 +66,7 @@ class FriendsResponseTest extends Specification {
"encode (one friend)" in {
val msg = FriendsResponse(
- FriendAction.UpdateFriend,
+ MemberAction.UpdateFriend,
0,
true,
true,
@@ -79,7 +80,7 @@ class FriendsResponseTest extends Specification {
"encode (multiple friends)" in {
val msg = FriendsResponse(
- FriendAction.InitializeFriendList,
+ MemberAction.InitializeFriendList,
0,
true,
true,
@@ -96,7 +97,7 @@ class FriendsResponseTest extends Specification {
}
"encode (short)" in {
- val msg = FriendsResponse(FriendAction.InitializeIgnoreList, 0, true, true)
+ val msg = FriendsResponse(MemberAction.InitializeIgnoreList, 0, true, true, Nil)
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual stringShort