Enemies (No Friends) (#1008)

* database, transfer objects and storage objects for lists of good friends, and of friends that you want to ignore

* friends and ignored players get added to lists, to the database, load in appropriate states, and update at basic appropriate times

* ignoring players and being ignored by players cuases loss of communication avenues, especially tells, and visibility

* modified the [friend list, ignored player list] x avatar query for better performance as the sizes of the results increases using joins and using targeted column selection

* obligatory fixes to tests that come with every update
This commit is contained in:
Fate-JH 2022-09-20 13:05:05 -04:00 committed by GitHub
parent 8747320307
commit 190a897dd5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 686 additions and 153 deletions

View file

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

View file

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

View file

@ -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<br>
* 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(" ")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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.<br>
* <br>
* 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.<br>
* <br>
* Actions:<br>
* 0 - display friends list<br>
* 1 - add player to friends list<br>
* 2 - remove player from friends list<br>
* 4 - display ignored player list<br>
* 5 - add player to ignored player list<br>
* 6 - remove player from ignored player list<br>
* <br>
* Exploration:<br>
* 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]
}

View file

@ -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)
* <br>
* 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.<br>
* <br>
* Actions:<br>
* 0 - initialize friends list (no logging)<br>
* 1 - add entry to friends list<br>
* 2 - remove entry from friends list<br>
* 3 - update status of player in friends list;
* if player is not listed, he is not added<br>
* 4 - initialize ignored players list (no logging)<br>
* 5 - add entry to ignored players list<br>
* 6 - remove entry from ignored players list<br>
* 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](

View file

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

View file

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

View file

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

View file

@ -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 _ => ;
}

View file

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

View file

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

View file

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

View file

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

View file

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