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