diff --git a/server/src/main/resources/db/migration/V005__Acquaintances.sql b/server/src/main/resources/db/migration/V005__Acquaintances.sql
index 00106609..1cc7e857 100644
--- a/server/src/main/resources/db/migration/V005__Acquaintances.sql
+++ b/server/src/main/resources/db/migration/V005__Acquaintances.sql
@@ -1,11 +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)
+ "char_id" INT NOT NULL REFERENCES avatar (id),
+ UNIQUE(avatar_id, char_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)
+ "char_id" INT NOT NULL REFERENCES avatar (id),
+ UNIQUE(avatar_id, char_id)
);
diff --git a/server/src/main/resources/db/migration/V006__SavedCharacters.sql b/server/src/main/resources/db/migration/V006__SavedCharacters.sql
new file mode 100644
index 00000000..8207a131
--- /dev/null
+++ b/server/src/main/resources/db/migration/V006__SavedCharacters.sql
@@ -0,0 +1,22 @@
+CREATE TABLE IF NOT EXISTS "savedplayer" (
+ "avatar_id" INT NOT NULL PRIMARY KEY REFERENCES avatar (id),
+ "px" INT NOT NULL,
+ "py" INT NOT NULL,
+ "pz" INT NOT NULL,
+ "orientation" INT NOT NULL,
+ "zone_num" SMALLINT NOT NULL,
+ "health" SMALLINT NOT NULL,
+ "armor" SMALLINT NOT NULL,
+ "exosuit_num" SMALLINT NOT NULL,
+ "loadout" TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS "savedavatar" (
+ "avatar_id" INT NOT NULL PRIMARY KEY REFERENCES avatar (id),
+ "forget_cooldown" TIMESTAMP NOT NULL,
+ "purchase_cooldowns" TEXT NOT NULL,
+ "use_cooldowns" TEXT NOT NULL
+);
+
+ALTER TABLE account
+ADD COLUMN last_faction_id SMALLINT DEFAULT 3
diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf
index 78ede4b5..0b676c16 100644
--- a/src/main/resources/application.conf
+++ b/src/main/resources/application.conf
@@ -147,6 +147,36 @@ game {
# When set, however, the next zone unlock is carried out regardless of the amount of time remaining
force-rotation-immediately = false
}
+
+ saved-msg = {
+ # A brief delay to the @charsaved message, in seconds.
+ # Use this when the message should display very soon for any reason.
+ short = {
+ # This delay always occurs
+ fixed = 5
+ # This delay is applied partially - fixed + [0,delay)
+ variable = 5
+ }
+
+ # A delay to the @charsaved message whenever a previous message has been displayed, in seconds.
+ # Used as the default interval between messages.
+ # It should provide assurance to the player even if nothing happened.
+ # Actual database interaction not assured.
+ renewal = {
+ fixed = 300
+ variable = 600
+ }
+
+ # A delay to the @charsaved message
+ # whenever an action that would cause actual database interaction occurs, in seconds.
+ # Actual database interaction not assured.
+ # The variability, in this case, serves the purpose of hedging against other activity by the player
+ # that would trigger the message again in a short amount of time.
+ interrupted-by-action = {
+ fixed = 15
+ variable = 30
+ }
+ }
}
anti-cheat {
diff --git a/src/main/scala/net/psforever/actors/session/AvatarActor.scala b/src/main/scala/net/psforever/actors/session/AvatarActor.scala
index 48495999..f4a1ac1c 100644
--- a/src/main/scala/net/psforever/actors/session/AvatarActor.scala
+++ b/src/main/scala/net/psforever/actors/session/AvatarActor.scala
@@ -5,6 +5,7 @@ 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.vital.{DamagingActivity, HealingActivity}
import org.joda.time.{LocalDateTime, Seconds}
//import org.log4s.Logger
import scala.collection.mutable
@@ -192,6 +193,48 @@ object AvatarActor {
final case class AvatarLoginResponse(avatar: Avatar)
+ /**
+ * A player loadout represents all of the items in the player's hands (equipment slots)
+ * and all of the items in the player's backpack (inventory)
+ * with items separated by meaningful punctuation marks.
+ * The CLOB - character large object - is a string of such item data
+ * that can be translated back into the original items
+ * and placed back in the same places in the inventory from which they were extracted.
+ * Together, these are occasionally referred to as an "inventory".
+ * @param owner the player whose inventory is being transcribed
+ * @return the resulting text data that represents an inventory
+ */
+ def buildClobFromPlayerLoadout(owner: Player): String = {
+ val clobber: mutable.StringBuilder = new mutable.StringBuilder()
+ //encode holsters
+ owner
+ .Holsters()
+ .zipWithIndex
+ .collect {
+ case (slot, index) if slot.Equipment.nonEmpty =>
+ clobber.append(encodeLoadoutClobFragment(slot.Equipment.get, index))
+ }
+ //encode inventory
+ owner.Inventory.Items.foreach {
+ case InventoryItem(obj, index) =>
+ clobber.append(encodeLoadoutClobFragment(obj, index))
+ }
+ clobber.mkString.drop(1) //drop leading punctuation
+ }
+
+ /**
+ * Transform from encoded inventory data as a CLOB - character large object - into individual items.
+ * Install those items into positions in a target container
+ * in the same positions in which they were previously recorded.
+ *
+ * There is no guarantee that the structure of the retained container data encoded in the CLOB
+ * will fit the current dimensions of the container.
+ * No tests are performed.
+ * A partial decompression of the CLOB may occur.
+ * @param container the container in which to place the pieces of equipment produced from the CLOB
+ * @param clob the inventory data in string form
+ * @param log a reference to a logging context
+ */
def buildContainedEquipmentFromClob(container: Container, clob: String, log: org.log4s.Logger): Unit = {
clob.split("/").filter(_.trim.nonEmpty).foreach { value =>
val (objectType, objectIndex, objectId, toolAmmo) = value.split(",") match {
@@ -222,7 +265,7 @@ object AvatarActor {
case "Telepad" | "BoomerTrigger" => ;
//special types of equipment that are not actually loaded
case name =>
- log.error(s"failing to add unknown equipment to a locker - $name")
+ log.error(s"failing to add unknown equipment to a container - $name")
}
toolAmmo foreach { toolAmmo =>
@@ -239,6 +282,57 @@ object AvatarActor {
}
}
+ /**
+ * Transform the encoded object to time data
+ * into proper object to proper time references
+ * and filter out mappings that have exceeded the sample duration.
+ * @param clob the entity to time data in string form
+ * @param cooldownDurations a base reference for entity to time comparison
+ * @param log a reference to a logging context
+ * @return the resulting text data that represents object to time mappings
+ */
+ def buildCooldownsFromClob(
+ clob: String,
+ cooldownDurations: Map[BasicDefinition,FiniteDuration],
+ log: org.log4s.Logger
+ ): Map[String, LocalDateTime] = {
+ val now = LocalDateTime.now()
+ val cooldowns: mutable.Map[String, LocalDateTime] = mutable.Map()
+ clob.split("/").filter(_.trim.nonEmpty).foreach { value =>
+ value.split(",") match {
+ case Array(name: String, b: String) =>
+ try {
+ val cooldown = LocalDateTime.parse(b)
+ cooldownDurations.get(DefinitionUtil.fromString(name)) match {
+ case Some(duration) if now.compareTo(cooldown.plusMillis(duration.toMillis.toInt)) == -1 =>
+ cooldowns.put(name, cooldown)
+ case _ => ;
+ }
+ } catch {
+ case _: Exception => ;
+ }
+ case _ =>
+ log.warn(s"ignoring invalid cooldown string: '$value'")
+ }
+ }
+ cooldowns.toMap
+ }
+
+ /**
+ * Transform the proper object to proper time references
+ * into encoded object to time data in a string format
+ * and filter out mappings that have exceeded the current time.
+ * @param cooldowns a base reference for entity to time comparison
+ * @return the resulting map that represents object to time string data
+ */
+ def buildClobfromCooldowns(cooldowns: Map[String, LocalDateTime]): String = {
+ val now = LocalDateTime.now()
+ cooldowns
+ .filter { case (_, cd) => cd.compareTo(now) == -1 }
+ .map { case (name, cd) => s"$name,$cd" }
+ .mkString("/")
+ }
+
def resolvePurchaseTimeName(faction: PlanetSideEmpire.Value, item: BasicDefinition): (BasicDefinition, String) = {
val factionName: String = faction.toString.toLowerCase
val name = item match {
@@ -431,6 +525,219 @@ object AvatarActor {
def onlineIfNotIgnored(onlinePlayer: Avatar, observedName: String): Boolean = {
!onlinePlayer.people.ignored.exists { f => f.name.equals(observedName) }
}
+
+ /**
+ * Query the database on information retained in regards to a certain character
+ * when that character had last logged out of the game.
+ * Dummy the data if no entries are found.
+ * @param avatarId the unique character identifier number
+ * @return when completed, a copy of data on that character from the database
+ */
+ def loadSavedPlayerData(avatarId: Long): Future[persistence.Savedplayer] = {
+ import ctx._
+ import scala.concurrent.ExecutionContext.Implicits.global
+ val out: Promise[persistence.Savedplayer] = Promise()
+ val queryResult = ctx.run(query[persistence.Savedplayer].filter { _.avatarId == lift(avatarId) })
+ queryResult.onComplete {
+ case Success(data) if data.nonEmpty =>
+ out.completeWith(Future(data.head))
+ case _ =>
+ ctx.run(query[persistence.Savedplayer]
+ .insert(
+ _.avatarId -> lift(avatarId),
+ _.px -> lift(0),
+ _.py -> lift(0),
+ _.pz -> lift(0),
+ _.orientation -> lift(0),
+ _.zoneNum -> lift(0),
+ _.health -> lift(0),
+ _.armor -> lift(0),
+ _.exosuitNum -> lift(0),
+ _.loadout -> lift("")
+ )
+ )
+ out.completeWith(Future(persistence.Savedplayer(avatarId, 0, 0, 0, 0, 0, 0, 0, 0, "")))
+ }
+ out.future
+ }
+//TODO should return number of rows inserted?
+ /**
+ * Query the database on information retained in regards to a certain character
+ * when that character had last logged out of the game.
+ * If that character is found in the database, update the data for that character.
+ * @param player the player character
+ * @return when completed, return the number of rows updated
+ */
+ def savePlayerData(player: Player): Future[Int] = {
+ savePlayerData(player, player.Health)
+ }
+
+ /**
+ * Query the database on information retained in regards to a certain character
+ * when that character had last logged out of the game.
+ * If that character is found in the database, update the data for that character.
+ * Determine if the player's previous health information is valid
+ * by comparing historical information about the player character's campaign.
+ * (This ignored the official health value attached to the character.)
+ * @param player the player character
+ * @return when completed, return the number of rows updated
+ */
+ def finalSavePlayerData(player: Player): Future[Int] = {
+ val health = (
+ player.History.find(_.isInstanceOf[DamagingActivity]),
+ player.History.find(_.isInstanceOf[HealingActivity])
+ ) match {
+ case (Some(damage), Some(heal)) =>
+ //between damage and potential healing, which came last?
+ if (damage.time < heal.time) {
+ heal.asInstanceOf[HealingActivity].amount % player.MaxHealth
+ } else {
+ damage.asInstanceOf[DamagingActivity].data.targetAfter.asInstanceOf[PlayerSource].health
+ }
+ case (Some(damage), None) =>
+ damage.asInstanceOf[DamagingActivity].data.targetAfter.asInstanceOf[PlayerSource].health
+ case (None, Some(heal)) =>
+ heal.asInstanceOf[HealingActivity].amount % player.MaxHealth
+ case _ =>
+ player.MaxHealth
+ }
+ savePlayerData(player, health)
+ }
+
+ /**
+ * Query the database on information retained in regards to a certain character
+ * when that character had last logged out of the game.
+ * If that character is found in the database, update the data for that character.
+ * If no entries for that character are found, insert a new default-data entry.
+ * @param player the player character
+ * @param health a custom health value to assign the player character's information in the database
+ * @return when completed, return the number of rows updated
+ */
+ def savePlayerData(player: Player, health: Int): Future[Int] = {
+ import ctx._
+ import scala.concurrent.ExecutionContext.Implicits.global
+ val out: Promise[Int] = Promise()
+ val avatarId = player.avatar.id
+ val position = player.Position
+ val queryResult = ctx.run(query[persistence.Savedplayer].filter { _.avatarId == lift(avatarId) })
+ queryResult.onComplete {
+ case Success(results) if results.nonEmpty =>
+ ctx.run(query[persistence.Savedplayer]
+ .filter { _.avatarId == lift(avatarId) }
+ .update(
+ _.px -> lift((position.x * 1000).toInt),
+ _.py -> lift((position.y * 1000).toInt),
+ _.pz -> lift((position.z * 1000).toInt),
+ _.orientation -> lift((player.Orientation.z * 1000).toInt),
+ _.zoneNum -> lift(player.Zone.Number),
+ _.health -> lift(health),
+ _.armor -> lift(player.Armor),
+ _.exosuitNum -> lift(player.ExoSuit.id),
+ _.loadout -> lift(buildClobFromPlayerLoadout(player))
+ )
+ )
+ out.completeWith(Future(1))
+ case _ =>
+ out.completeWith(Future(0))
+ }
+ out.future
+ }
+
+ /**
+ * Query the database on information retained in regards to a certain character
+ * when that character had last logged out of the game.
+ * If that character is found in the database, update only specific fields for that character
+ * related to the character's physical location in the game world.
+ * @param player the player character
+ * @return when completed, return the number of rows updated
+ */
+ def savePlayerLocation(player: Player): Future[Int] = {
+ import ctx._
+ import scala.concurrent.ExecutionContext.Implicits.global
+ val out: Promise[Int] = Promise()
+ val avatarId = player.avatar.id
+ val position = player.Position
+ val queryResult = ctx.run(query[persistence.Savedplayer].filter { _.avatarId == lift(avatarId) })
+ queryResult.onComplete {
+ case Success(results) if results.nonEmpty =>
+ val res=ctx.run(query[persistence.Savedplayer]
+ .filter { _.avatarId == lift(avatarId) }
+ .update(
+ _.px -> lift((position.x * 1000).toInt),
+ _.py -> lift((position.y * 1000).toInt),
+ _.pz -> lift((position.z * 1000).toInt),
+ _.orientation -> lift((player.Orientation.z * 1000).toInt),
+ _.zoneNum -> lift(player.Zone.Number)
+ )
+ )
+ out.completeWith(Future(1))
+ case _ =>
+ out.completeWith(Future(0))
+ }
+ out.future
+ }
+
+ /**
+ * Query the database on information retained in regards to a certain player avatar
+ * when a character associated with the avatar had last logged out of the game.
+ * If that player avatar is found in the database, recover the retained information.
+ * If no entries for that avatar are found, insert a new default-data entry and dummy an entry for use.
+ * Useful mainly for player avatar login evaluations.
+ * @param avatarId a unique identifier number associated with the player avatar
+ * @return when completed, return the persisted data
+ */
+ def loadSavedAvatarData(avatarId: Long): Future[persistence.Savedavatar] = {
+ import ctx._
+ import scala.concurrent.ExecutionContext.Implicits.global
+ val out: Promise[persistence.Savedavatar] = Promise()
+ val queryResult = ctx.run(query[persistence.Savedavatar].filter { _.avatarId == lift(avatarId) })
+ queryResult.onComplete {
+ case Success(data) if data.nonEmpty =>
+ out.completeWith(Future(data.head))
+ case _ =>
+ val now = LocalDateTime.now()
+ ctx.run(query[persistence.Savedavatar]
+ .insert(
+ _.avatarId -> lift(avatarId),
+ _.forgetCooldown -> lift(now),
+ _.purchaseCooldowns -> lift(""),
+ _.useCooldowns -> lift("")
+ )
+ )
+ out.completeWith(Future(persistence.Savedavatar(avatarId, now, "", "")))
+ }
+ out.future
+ }
+
+ /**
+ * Query the database on information retained in regards to a certain player avatar
+ * when a character associated with the avatar had last logged out of the game.
+ * If that player avatar is found in the database, update important information.
+ * Useful mainly for player avatar login evaluations.
+ * @param avatar a unique player avatar
+ * @return when completed, return the number of rows updated
+ */
+ def saveAvatarData(avatar: Avatar): Future[Int] = {
+ import ctx._
+ import scala.concurrent.ExecutionContext.Implicits.global
+ val out: Promise[Int] = Promise()
+ val avatarId = avatar.id
+ val queryResult = ctx.run(query[persistence.Savedavatar].filter { _.avatarId == lift(avatarId) })
+ queryResult.onComplete {
+ case Success(results) if results.nonEmpty =>
+ ctx.run(query[persistence.Savedavatar]
+ .filter { _.avatarId == lift(avatarId) }
+ .update(
+ _.purchaseCooldowns -> lift(buildClobfromCooldowns(avatar.cooldowns.purchase)),
+ _.useCooldowns -> lift(buildClobfromCooldowns(avatar.cooldowns.use))
+ )
+ )
+ out.completeWith(Future(1))
+ case _ =>
+ out.completeWith(Future(0))
+ }
+ out.future
+ }
}
class AvatarActor(
@@ -568,14 +875,30 @@ class AvatarActor(
val result = for {
_ <- ctx.run(query[persistence.Implant].filter(_.avatarId == lift(id)).delete)
_ <- ctx.run(query[persistence.Loadout].filter(_.avatarId == lift(id)).delete)
+ _ <- ctx.run(query[persistence.Locker].filter(_.avatarId == lift(id)).delete)
_ <- ctx.run(query[persistence.Certification].filter(_.avatarId == lift(id)).delete)
- r <- ctx.run(query[persistence.Avatar].filter(_.id == lift(id)).delete)
+ _ <- ctx.run(query[persistence.Friend].filter(_.avatarId == lift(id)).delete)
+ _ <- ctx.run(query[persistence.Ignored].filter(_.avatarId == lift(id)).delete)
+ _ <- ctx.run(query[persistence.Savedavatar].filter(_.avatarId == lift(id)).delete)
+ _ <- ctx.run(query[persistence.Savedplayer].filter(_.avatarId == lift(id)).delete)
+ r <- ctx.run(query[persistence.Avatar].filter(_.id == lift(id)))
} yield r
result.onComplete {
- case Success(_) =>
- log.debug(s"AvatarActor: avatar $id deleted")
- sessionActor ! SessionActor.SendResponse(ActionResultMessage.Pass)
+ case Success(deleted) =>
+ deleted.headOption match {
+ case Some(a) if !a.deleted =>
+ ctx.run(query[persistence.Avatar]
+ .filter(_.id == lift(id))
+ .update(
+ _.deleted -> lift(true),
+ _.lastModified -> lift(LocalDateTime.now())
+ )
+ )
+ log.debug(s"AvatarActor: avatar $id deleted")
+ sessionActor ! SessionActor.SendResponse(ActionResultMessage.Pass)
+ case _ => ;
+ }
sendAvatars(account)
case Failure(e) => log.error(e)("db failure")
}
@@ -597,32 +920,42 @@ class AvatarActor(
case LoginAvatar(replyTo) =>
import ctx._
-
+ val avatarId = avatar.id
val result = for {
- _ <- ctx.run(
- query[persistence.Avatar].filter(_.id == lift(avatar.id))
- .update(_.lastLogin -> lift(LocalDateTime.now()))
+ //log this login
+ _ <- ctx.run(query[persistence.Avatar].filter(_.id == lift(avatarId))
+ .update(_.lastLogin -> lift(LocalDateTime.now()))
)
- avatarId = avatar.id
+ //log this choice of faction (no empire switching)
+ _ <- ctx.run(query[persistence.Account].filter(_.id == lift(account.id))
+ .update(_.lastFactionId -> lift(avatar.faction.id))
+ )
+ //retrieve avatar data
loadouts <- initializeAllLoadouts()
implants <- ctx.run(query[persistence.Implant].filter(_.avatarId == lift(avatarId)))
certs <- ctx.run(query[persistence.Certification].filter(_.avatarId == lift(avatarId)))
locker <- loadLocker(avatarId)
friends <- loadFriendList(avatarId)
ignored <- loadIgnoredList(avatarId)
- } yield (loadouts, implants, certs, locker, friends, ignored)
-
+ saved <- AvatarActor.loadSavedAvatarData(avatarId)
+ } yield (loadouts, implants, certs, locker, friends, ignored, saved)
result.onComplete {
- case Success((_loadouts, implants, certs, locker, friendsList, ignoredList)) =>
+ case Success((_loadouts, implants, certs, locker, friendsList, ignoredList, saved)) =>
avatarCopy(
avatar.copy(
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,
- people = MemberLists(friendsList, ignoredList)
+ people = MemberLists(
+ friend = friendsList,
+ ignored = ignoredList
+ ),
+ cooldowns = Cooldowns(
+ purchase = AvatarActor.buildCooldownsFromClob(saved.purchaseCooldowns, Avatar.purchaseCooldowns, log),
+ use = AvatarActor.buildCooldownsFromClob(saved.useCooldowns, Avatar.useCooldowns, log)
+ )
)
)
// if we need to start stamina regeneration
@@ -699,6 +1032,7 @@ class AvatarActor(
sessionActor ! SessionActor.SendResponse(
ItemTransactionResultMessage(terminalGuid, TransactionType.Sell, success = true)
)
+ sessionActor ! SessionActor.CharSaved
}
}
@@ -754,6 +1088,7 @@ class AvatarActor(
sessionActor ! SessionActor.SendResponse(
ItemTransactionResultMessage(terminalGuid, TransactionType.Sell, success = true)
)
+ sessionActor ! SessionActor.CharSaved
//wearing invalid armor?
if (
if (certification == Certification.ReinforcedExoSuit) player.ExoSuit == ExoSuitType.Reinforced
@@ -807,6 +1142,7 @@ class AvatarActor(
.onComplete {
case Success(_) =>
replaceAvatar(avatar.copy(certifications = certifications))
+ sessionActor ! SessionActor.CharSaved
case Failure(exception) =>
log.error(exception)("db failure")
}
@@ -855,6 +1191,7 @@ class AvatarActor(
ItemTransactionResultMessage(terminalGuid, TransactionType.Learn, success = true)
)
context.self ! ResetImplants()
+ sessionActor ! SessionActor.CharSaved
case Failure(exception) => log.error(exception)("db failure")
}
@@ -890,6 +1227,7 @@ class AvatarActor(
ItemTransactionResultMessage(terminalGuid, TransactionType.Sell, success = true)
)
context.self ! ResetImplants()
+ sessionActor ! SessionActor.CharSaved
case Failure(exception) => log.error(exception)("db failure")
}
@@ -937,6 +1275,7 @@ class AvatarActor(
case Success(loadout) =>
val ldouts = avatar.loadouts
replaceAvatar(avatar.copy(loadouts = ldouts.copy(suit = ldouts.suit.updated(lineNo, Some(loadout)))))
+ sessionActor ! SessionActor.CharSaved
refreshLoadout(lineNo)
case Failure(exception) =>
log.error(exception)("db failure (?)")
@@ -946,7 +1285,7 @@ class AvatarActor(
case DeleteLoadout(player, loadoutType, number) =>
log.info(s"${player.Name} wishes to delete a favorite $loadoutType loadout - #${number + 1}")
import ctx._
- val (lineNo, result) = loadoutType match {
+ val (lineNo: Int, result) = loadoutType match {
case LoadoutType.Infantry if avatar.loadouts.suit(number).nonEmpty =>
(
number,
@@ -984,6 +1323,7 @@ class AvatarActor(
case Success(_) =>
val ldouts = avatar.loadouts
avatarCopy(avatar.copy(loadouts = ldouts.copy(suit = ldouts.suit.updated(lineNo, None))))
+ sessionActor ! SessionActor.CharSaved
sessionActor ! SessionActor.SendResponse(FavoritesMessage(loadoutType, player.GUID, number, ""))
case Failure(exception) =>
log.error(exception)("db failure (?)")
@@ -1003,7 +1343,6 @@ class AvatarActor(
Behaviors.same
case UpdatePurchaseTime(definition, time) =>
- // TODO save to db
var newTimes = avatar.cooldowns.purchase
AvatarActor.resolveSharedPurchaseTimeNames(AvatarActor.resolvePurchaseTimeName(avatar.faction, definition)).foreach {
case (item, name) =>
@@ -1281,6 +1620,7 @@ class AvatarActor(
}
.receiveSignal {
case (_, PostStop) =>
+ AvatarActor.saveAvatarData(avatar)
staminaRegenTimer.cancel()
implantTimers.values.foreach(_.cancel())
saveLockerFunc()
@@ -1323,8 +1663,11 @@ class AvatarActor(
avatarCopy(avatar.copy(decoration = avatar.decoration.copy(cosmetics = Some(cosmetics))))
session.get.zone.AvatarEvents ! AvatarServiceMessage(
session.get.zone.id,
- AvatarAction
- .PlanetsideAttributeToAll(session.get.player.GUID, 106, Cosmetic.valuesToAttributeValue(cosmetics))
+ AvatarAction.PlanetsideAttributeToAll(
+ session.get.player.GUID,
+ 106,
+ Cosmetic.valuesToAttributeValue(cosmetics)
+ )
)
p.success(())
case Failure(exception) =>
@@ -1631,23 +1974,8 @@ class AvatarActor(
def storeLoadout(owner: Player, label: String, line: Int): Future[Loadout] = {
import ctx._
- val items: String = {
- val clobber: mutable.StringBuilder = new StringBuilder()
- //encode holsters
- owner
- .Holsters()
- .zipWithIndex
- .collect {
- case (slot, index) if slot.Equipment.nonEmpty =>
- clobber.append(AvatarActor.encodeLoadoutClobFragment(slot.Equipment.get, index))
- }
- //encode inventory
- owner.Inventory.Items.foreach {
- case InventoryItem(obj, index) =>
- clobber.append(AvatarActor.encodeLoadoutClobFragment(obj, index))
- }
- clobber.mkString.drop(1)
- }
+ sessionActor ! SessionActor.CharSaved
+ val items: String = AvatarActor.buildClobFromPlayerLoadout(owner)
for {
loadouts <- ctx.run(
query[persistence.Loadout].filter(_.avatarId == lift(owner.CharId)).filter(_.loadoutNumber == lift(line))
@@ -1691,6 +2019,7 @@ class AvatarActor(
clobber.mkString.drop(1)
}
+ sessionActor ! SessionActor.CharSaved
for {
loadouts <- ctx.run(
query[persistence.Vehicleloadout]
@@ -1743,6 +2072,7 @@ class AvatarActor(
def pushLockerClobToDataBase(items: String): Database.ctx.Result[Database.ctx.RunActionResult] = {
import ctx._
+ sessionActor ! SessionActor.CharSaved
ctx.run(
query[persistence.Locker]
.filter(_.avatarId == lift(avatar.id))
@@ -2116,6 +2446,7 @@ class AvatarActor(
people = people.copy(friend = people.friend :+ AvatarFriend(charId, name, PlanetSideEmpire(faction), isOnline))
))
sessionActor ! SessionActor.SendResponse(FriendsResponse(MemberAction.AddFriend, GameFriend(name, isOnline)))
+ sessionActor ! SessionActor.CharSaved
}
}
@@ -2141,6 +2472,7 @@ class AvatarActor(
.delete
)
sessionActor ! SessionActor.SendResponse(FriendsResponse(MemberAction.RemoveFriend, GameFriend(name)))
+ sessionActor ! SessionActor.CharSaved
}
/**
@@ -2202,6 +2534,7 @@ class AvatarActor(
avatar.copy(people = people.copy(ignored = people.ignored :+ AvatarIgnored(charId, name)))
)
sessionActor ! SessionActor.UpdateIgnoredPlayers(FriendsResponse(MemberAction.AddIgnoredPlayer, GameFriend(name)))
+ sessionActor ! SessionActor.CharSaved
}
}
@@ -2229,5 +2562,6 @@ class AvatarActor(
.delete
)
sessionActor ! SessionActor.UpdateIgnoredPlayers(FriendsResponse(MemberAction.RemoveIgnoredPlayer, GameFriend(name)))
+ sessionActor ! SessionActor.CharSaved
}
}
diff --git a/src/main/scala/net/psforever/actors/session/SessionActor.scala b/src/main/scala/net/psforever/actors/session/SessionActor.scala
index 9252375e..f8fa791f 100644
--- a/src/main/scala/net/psforever/actors/session/SessionActor.scala
+++ b/src/main/scala/net/psforever/actors/session/SessionActor.scala
@@ -32,6 +32,7 @@ import net.psforever.objects.serverobject.mblocker.Locker
import net.psforever.objects.serverobject.mount.Mountable
import net.psforever.objects.serverobject.pad.VehicleSpawnPad
import net.psforever.objects.serverobject.resourcesilo.ResourceSilo
+import net.psforever.objects.serverobject.shuttle.OrbitalShuttlePad
import net.psforever.objects.serverobject.structures.{Amenity, Building, StructureType, WarpGate}
import net.psforever.objects.serverobject.terminals._
import net.psforever.objects.serverobject.terminals.capture.CaptureTerminal
@@ -126,6 +127,10 @@ object SessionActor {
final case class UpdateIgnoredPlayers(msg: FriendsResponse) extends Command
+ final case object CharSaved extends Command
+
+ private case object CharSavedMsg extends Command
+
/**
* The message that progresses some form of user-driven activity with a certain eventual outcome
* and potential feedback per cycle.
@@ -257,11 +262,12 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
* As they should arrive roughly every 250 milliseconds this allows for a very crude method of scheduling tasks up to four times per second
*/
var upstreamMessageCount: Int = 0
- var zoningType: Zoning.Method.Value = Zoning.Method.None
+ var zoningType: Zoning.Method = Zoning.Method.None
var zoningChatMessageType: ChatMessageType = ChatMessageType.CMT_QUIT
- var zoningStatus: Zoning.Status.Value = Zoning.Status.None
+ var zoningStatus: Zoning.Status = Zoning.Status.None
var zoningCounter: Int = 0
var instantActionFallbackDestination: Option[Zoning.InstantAction.Located] = None
+ var loginChatMessage: String = ""
lazy val unsignedIntMaxValue: Long = Int.MaxValue.toLong * 2L + 1L
var serverTime: Long = 0
var amsSpawnPoints: List[SpawnPoint] = Nil
@@ -293,6 +299,7 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
var reviveTimer: Cancellable = Default.Cancellable
var respawnTimer: Cancellable = Default.Cancellable
var zoningTimer: Cancellable = Default.Cancellable
+ var charSavedTimer: Cancellable = Default.Cancellable
override def supervisorStrategy: SupervisorStrategy = {
import net.psforever.objects.inventory.InventoryDisarrayException
@@ -541,6 +548,7 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
override def postStop(): Unit = {
//normally, the player avatar persists a minute or so after disconnect; we are subject to the SessionReaper
+ charSavedTimer.cancel()
clientKeepAlive.cancel()
progressBarUpdate.cancel()
reviveTimer.cancel()
@@ -660,6 +668,18 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
session = session.copy(avatar = avatar)
*/
+ case CharSaved =>
+ renewCharSavedTimer(
+ Config.app.game.savedMsg.interruptedByAction.fixed,
+ Config.app.game.savedMsg.interruptedByAction.variable
+ )
+
+ case CharSavedMsg =>
+ displayCharSavedMsgThenRenewTimer(
+ Config.app.game.savedMsg.renewal.fixed,
+ Config.app.game.savedMsg.renewal.variable
+ )
+
case SetAvatar(avatar) =>
session = session.copy(avatar = avatar)
if (session.player != null) {
@@ -669,7 +689,7 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
case AvatarActor.AvatarResponse(avatar) =>
session = session.copy(avatar = avatar)
- accountPersistence ! AccountPersistenceService.Login(avatar.name)
+ accountPersistence ! AccountPersistenceService.Login(avatar.name, avatar.id)
case AvatarActor.AvatarLoginResponse(avatar) =>
avatarLoginResponse(avatar)
@@ -1290,38 +1310,35 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
cluster ! ICS.GetInstantActionSpawnPoint(player.Faction, context.self)
})
+ case Zoning.Method.Reset =>
+ player.ZoningRequest = Zoning.Method.Login
+ zoningType = Zoning.Method.Login
+ response match {
+ case Some((zone, spawnPoint)) =>
+ loginChatMessage = "@login_reposition_to_friendly_facility" //Your previous location was held by the enemy. You have been moved to the nearest friendly facility.
+ val (pos, ori) = spawnPoint.SpecificPoint(player)
+ LoadZonePhysicalSpawnPoint(zone.id, pos, ori, respawnTime = 0 seconds, Some(spawnPoint))
+ case _ =>
+ loginChatMessage = "@login_reposition_to_sanctuary" //Your previous location was held by the enemy. As there were no operational friendly facilities on that continent, you have been brought back to your Sanctuary.
+ RequestSanctuaryZoneSpawn(player, player.Zone.Number)
+ }
+
+ case Zoning.Method.Login =>
+ resolveZoningSpawnPointLoad(response, Zoning.Method.Login)
+
case ztype =>
if (ztype != Zoning.Method.None) {
log.warn(
s"SpawnPointResponse: ${player.Name}'s zoning was not in order at the time a response was received; attempting to guess what ${player.Sex.pronounSubject} wants to do"
)
}
- val previousZoningType = zoningType
+ val previousZoningType = ztype
CancelZoningProcess()
PlayerActionsToCancel()
CancelAllProximityUnits()
DropSpecialSlotItem()
continent.Population ! Zone.Population.Release(avatar)
- response match {
- case Some((zone, spawnPoint)) =>
- val obj = continent.GUID(player.VehicleSeated) match {
- case Some(obj: Vehicle) if !obj.Destroyed => obj
- case _ => player
- }
- val (pos, ori) = spawnPoint.SpecificPoint(obj)
- if (previousZoningType == Zoning.Method.InstantAction)
- LoadZonePhysicalSpawnPoint(zone.id, pos, ori, respawnTime = 0 seconds, Some(spawnPoint))
- else
- LoadZonePhysicalSpawnPoint(zone.id, pos, ori, CountSpawnDelay(zone.id, spawnPoint, continent.id), Some(spawnPoint))
- case None =>
- log.warn(
- s"SpawnPointResponse: ${player.Name} received no spawn point response when asking InterstellarClusterService"
- )
- if (Config.app.game.warpGates.defaultToSanctuaryDestination) {
- log.warn(s"SpawnPointResponse: sending ${player.Name} home")
- RequestSanctuaryZoneSpawn(player, currentZone = 0)
- }
- }
+ resolveZoningSpawnPointLoad(response, previousZoningType)
}
case ICS.DroppodLaunchDenial(errorCode, _) =>
@@ -1428,6 +1445,7 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
squadService ! Service.Join(s"${avatar.id}") //channel will be player.CharId (in order to work with packets)
player.Zone match {
case Zone.Nowhere =>
+ RandomSanctuarySpawnPosition(player)
RequestSanctuaryZoneSpawn(player, currentZone = 0)
case zone =>
log.trace(s"ZoneResponse: zone ${zone.id} will now load for ${player.Name}")
@@ -1440,8 +1458,15 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
oldZone.AvatarEvents ! Service.Leave()
oldZone.LocalEvents ! Service.Leave()
oldZone.VehicleEvents ! Service.Leave()
- if (player.isAlive) {
- self ! NewPlayerLoaded(player)
+
+ if (player.isAlive && zoningType != Zoning.Method.Reset) {
+ if (player.HasGUID) {
+ HandleNewPlayerLoaded(player)
+ } else {
+ //alive but doesn't have a GUID; probably logging in?
+ _session = _session.copy(zone = Zone.Nowhere)
+ self ! ICS.ZoneResponse(Some(player.Zone))
+ }
} else {
zoneReload = true
cluster ! ICS.GetNearbySpawnPoint(
@@ -1453,71 +1478,11 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
}
}
- case ICS.ZoneResponse(zone) =>
- log.trace(s"ZoneResponse: zone ${zone.get.id} will now load for ${player.Name}")
- loadConfZone = true
- val oldZone = session.zone
- session = session.copy(zone = zone.get)
- //the only zone-level event system subscription necessary before BeginZoningMessage (for persistence purposes)
- continent.AvatarEvents ! Service.Join(player.Name)
- persist()
- oldZone.AvatarEvents ! Service.Leave()
- oldZone.LocalEvents ! Service.Leave()
- oldZone.VehicleEvents ! Service.Leave()
- continent.Population ! Zone.Population.Join(avatar)
- player.avatar = avatar
- interstellarFerry match {
- case Some(vehicle) if vehicle.PassengerInSeat(player).contains(0) =>
- TaskWorkflow.execute(registerDrivenVehicle(vehicle, player))
- case _ =>
- TaskWorkflow.execute(registerNewAvatar(player))
- }
+ case ICS.ZoneResponse(Some(zone)) =>
+ HandleZoneResponse(zone)
case NewPlayerLoaded(tplayer) =>
- //new zone
- log.info(s"${tplayer.Name} has spawned into ${session.zone.id}")
- oldRefsMap.clear()
- persist = UpdatePersistenceAndRefs
- tplayer.avatar = avatar
- session = session.copy(player = tplayer)
- avatarActor ! AvatarActor.CreateImplants()
- avatarActor ! AvatarActor.InitializeImplants()
- //LoadMapMessage causes the client to send BeginZoningMessage, eventually leading to SetCurrentAvatar
- val weaponsEnabled =
- session.zone.map.name != "map11" && session.zone.map.name != "map12" && session.zone.map.name != "map13"
- sendResponse(
- LoadMapMessage(
- session.zone.map.name,
- session.zone.id,
- 40100,
- 25,
- weaponsEnabled,
- session.zone.map.checksum
- )
- )
- if (isAcceptableNextSpawnPoint()) {
- //important! the LoadMapMessage must be processed by the client before the avatar is created
- setupAvatarFunc()
- //interimUngunnedVehicle should have been setup by setupAvatarFunc, if it is applicable
- turnCounterFunc = interimUngunnedVehicle match {
- case Some(_) =>
- TurnCounterDuringInterimWhileInPassengerSeat
- case None =>
- TurnCounterDuringInterim
- }
- keepAliveFunc = NormalKeepAlive
- upstreamMessageCount = 0
- setAvatar = false
- persist()
- } else {
- //look for different spawn point in same zone
- cluster ! ICS.GetNearbySpawnPoint(
- session.zone.Number,
- tplayer,
- Seq(SpawnGroup.Facility, SpawnGroup.Tower, SpawnGroup.AMS),
- context.self
- )
- }
+ HandleNewPlayerLoaded(tplayer)
case PlayerLoaded(tplayer) =>
//same zone
@@ -1652,24 +1617,101 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
avatarActor ! AvatarActor.SetAccount(account)
case PlayerToken.LoginInfo(name, Zone.Nowhere, _) =>
- log.info(s"LoginInfo: player $name is considered a new character")
- //TODO poll the database for saved zone and coordinates?
+ log.info(s"LoginInfo: player $name is considered a fresh character")
persistFunc = UpdatePersistence(sender())
deadState = DeadState.RespawnTime
-
- session = session.copy(player = new Player(avatar))
- //xy-coordinates indicate sanctuary spawn bias:
- player.Position = math.abs(scala.util.Random.nextInt() % avatar.name.hashCode % 4) match {
- case 0 => Vector3(8192, 8192, 0) //NE
- case 1 => Vector3(8192, 0, 0) //SE
- case 2 => Vector3(0, 0, 0) //SW
- case 3 => Vector3(0, 8192, 0) //NW
- }
- DefinitionUtil.applyDefaultLoadout(player)
+ val tplayer = new Player(avatar)
+ session = session.copy(player = tplayer)
+ //actual zone is undefined; going to our sanctuary
+ RandomSanctuarySpawnPosition(tplayer)
+ DefinitionUtil.applyDefaultLoadout(tplayer)
avatarActor ! AvatarActor.LoginAvatar(context.self)
- case PlayerToken.LoginInfo(playerName, inZone, pos) =>
- log.info(s"LoginInfo: player $playerName is already logged in zone ${inZone.id}; rejoining that character")
+ case PlayerToken.LoginInfo(name, inZone, optionalSavedData) =>
+ log.info(s"LoginInfo: player $name is considered a fresh character")
+ persistFunc = UpdatePersistence(sender())
+ deadState = DeadState.RespawnTime
+ session = session.copy(player = new Player(avatar))
+ player.Zone = inZone
+ optionalSavedData match {
+ case Some(results) =>
+ val health = results.health
+ val hasHealthUponLogin = health > 0
+ val position = Vector3(results.px * 0.001f, results.py * 0.001f, results.pz * 0.001f)
+ player.Position = position
+ player.Orientation = Vector3(0f, 0f, results.orientation * 0.001f)
+ /*
+ @reset_sanctuary=You have been returned to the sanctuary because you played another character.
+ */
+ if (hasHealthUponLogin) {
+ player.Spawn()
+ player.Health = health
+ player.Armor = results.armor
+ player.ExoSuit = ExoSuitType(results.exosuitNum)
+ AvatarActor.buildContainedEquipmentFromClob(player, results.loadout, log)
+ } else {
+ player.ExoSuit = ExoSuitType.Standard
+ DefinitionUtil.applyDefaultLoadout(player)
+ }
+ if (player.isAlive) {
+ zoningType = Zoning.Method.Login
+ player.ZoningRequest = Zoning.Method.Login
+ zoningChatMessageType = ChatMessageType.UNK_227
+ if (Zones.sanctuaryZoneNumber(player.Faction) != inZone.Number) {
+ val pfaction = player.Faction
+ val buildings = inZone.Buildings.values
+ val ourBuildings = buildings.filter { _.Faction == pfaction }.toSeq
+ val playersInZone = inZone.Players
+ val friendlyPlayersInZone = playersInZone.count { _.faction == pfaction }
+ val noFriendlyPlayersInZone = friendlyPlayersInZone == 0
+ if (inZone.map.cavern) {
+ loginChatMessage = "@reset_sanctuary_locked"
+ //You have been returned to the sanctuary because the location you logged out is not available.
+ player.Zone = Zone.Nowhere
+ } else if (ourBuildings.isEmpty && (amsSpawnPoints.isEmpty || noFriendlyPlayersInZone)) {
+ loginChatMessage = "@reset_sanctuary_locked"
+ //You have been returned to the sanctuary because the location you logged out is not available.
+ player.Zone = Zone.Nowhere
+ } else if (friendlyPlayersInZone > 137 || playersInZone.size > 413) {
+ loginChatMessage = "@reset_sanctuary_full"
+ //You have been returned to the sanctuary because the zone you logged out on is full.
+ player.Zone = Zone.Nowhere
+ } else {
+ val inBuildingSOI = buildings.filter { b =>
+ val soi2 = b.Definition.SOIRadius * b.Definition.SOIRadius
+ Vector3.DistanceSquared(b.Position, position) < soi2
+ }
+ if (inBuildingSOI.nonEmpty) {
+ if (!inBuildingSOI.exists { ourBuildings.contains }) {
+ zoningType = Zoning.Method.Reset
+ player.ZoningRequest = Zoning.Method.Reset
+ zoningChatMessageType = ChatMessageType.UNK_228
+ }
+ } else {
+ if (noFriendlyPlayersInZone) {
+ loginChatMessage = "@reset_sanctuary_inactive"
+ //You have been returned to the sanctuary because the location you logged out is not available.
+ player.Zone = Zone.Nowhere
+ }
+ }
+ }
+ }
+ } else {
+ //player is dead; go back to sanctuary
+ loginChatMessage = "@reset_sanctuary_inactive"
+ //You have been returned to the sanctuary because the location you logged out is not available.
+ player.Zone = Zone.Nowhere
+ }
+
+ case None =>
+ player.Spawn()
+ player.ExoSuit = ExoSuitType.Standard
+ DefinitionUtil.applyDefaultLoadout(player)
+ }
+ avatarActor ! AvatarActor.LoginAvatar(context.self)
+
+ case PlayerToken.RestoreInfo(playerName, inZone, pos) =>
+ log.info(s"RestoreInfo: player $playerName is already logged in zone ${inZone.id}; rejoining that character")
persistFunc = UpdatePersistence(sender())
//tell the old WorldSessionActor to kill itself by using its own subscriptions against itself
inZone.AvatarEvents ! AvatarServiceMessage(playerName, AvatarAction.TeardownConnection())
@@ -1684,7 +1726,7 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
case (Some(a), Some(p)) if p.isAlive =>
//rejoin current avatar/player
- log.info(s"LoginInfo: player $playerName is alive")
+ log.info(s"RestoreInfo: player $playerName is alive")
deadState = DeadState.Alive
session = session.copy(player = p, avatar = a)
persist()
@@ -1694,7 +1736,7 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
case (Some(a), Some(p)) =>
//convert player to a corpse (unless in vehicle); automatic recall to closest spawn point
- log.info(s"LoginInfo: player $playerName is dead")
+ log.info(s"RestoreInfo: player $playerName is dead")
deadState = DeadState.Dead
session = session.copy(player = p, avatar = a)
persist()
@@ -1705,7 +1747,7 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
case (Some(a), None) =>
//respawn avatar as a new player; automatic recall to closest spawn point
- log.info(s"LoginInfo: player $playerName had released recently")
+ log.info(s"RestoreInfo: player $playerName had released recently")
deadState = DeadState.RespawnTime
session = session.copy(
player = inZone.Corpses.findLast(c => c.Name == playerName) match {
@@ -1724,8 +1766,8 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
case _ =>
//fall back to sanctuary/prior?
- log.info(s"LoginInfo: player $playerName could not be found in game world")
- self.forward(PlayerToken.LoginInfo(playerName, Zone.Nowhere, pos))
+ log.info(s"RestoreInfo: player $playerName could not be found in game world")
+ self.forward(PlayerToken.LoginInfo(playerName, Zone.Nowhere, None))
}
case PlayerToken.CanNotLogin(playerName, reason) =>
@@ -1750,6 +1792,39 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
log.warn(s"Invalid packet class received: $default from ${sender()}")
}
+ def RandomSanctuarySpawnPosition(target: Player): Unit = {
+ //xy-coordinates indicate spawn bias:
+ val sanctuaryNum = Zones.sanctuaryZoneNumber(target.Faction)
+ val harts = Zones.zones.find(zone => zone.Number == sanctuaryNum) match {
+ case Some(zone) => zone.Buildings
+ .values
+ .filter(b => b.Amenities.exists { a: Amenity => a.isInstanceOf[OrbitalShuttlePad] })
+ .toSeq
+ case None =>
+ Nil
+ }
+ //compass directions to modify spawn destination
+ val directionBias = math.abs(scala.util.Random.nextInt() % avatar.name.hashCode % 8) match {
+ case 0 => Vector3(-1, 1,0) //NW
+ case 1 => Vector3( 0, 1,0) //N
+ case 2 => Vector3( 1, 1,0) //NE
+ case 3 => Vector3( 1, 0,0) //E
+ case 4 => Vector3( 1,-1,0) //SE
+ case 5 => Vector3( 0,-1,0) //S
+ case 6 => Vector3(-1,-1,0) //SW
+ case 7 => Vector3(-1, 0,0) //W
+ }
+ if (harts.nonEmpty) {
+ //get a hart building and select one of the spawn facilities surrounding it
+ val campusLocation = harts(math.floor(math.abs(math.random()) * harts.size).toInt).Position
+ target.Position = campusLocation + directionBias
+ } else {
+ //weird issue here; should we log?
+ //select closest spawn point based on global cardinal or ordinal direction bias
+ target.Position = directionBias * 8192f
+ }
+ }
+
/**
* Update this player avatar for persistence.
* Set to `persist` initially.
@@ -1863,6 +1938,45 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
(location.id, location.descriptor.toLowerCase)
}
+ /**
+ * Process recovered spawn request information to start the process of spawning an avatar player entity
+ * in a specific zone in a specific place in that zone after a certain amount of time has elapsed.
+ *
+ * To load: a zone, a spawn point, a spawning target entity, and the time it takes to spawn are required.
+ * Everything but the spawn point can be determined from the information already available to the context
+ * (does not need to be passed in as a parameter).
+ * The zone is more reliable when passed in as a parameter since local references may have changed.
+ * The spawn point defines the spawn position as well as the spawn orientation.
+ * Any of information provided can be used to calculate the time to spawn.
+ * The session's knowledge of the zoning event is also used to assist with the spawning event.
+ *
+ * If no spawn information has been provided, abort the whole process (unsafe!).
+ * @param spawnPointTarget an optional paired zone entity and a spawn point within the zone
+ * @param zoningType a token that references the manner of zone transfer
+ */
+ def resolveZoningSpawnPointLoad(spawnPointTarget: Option[(Zone, SpawnPoint)], zoningType: Zoning.Method): Unit = {
+ spawnPointTarget match {
+ case Some((zone, spawnPoint)) =>
+ val obj = continent.GUID(player.VehicleSeated) match {
+ case Some(obj: Vehicle) if !obj.Destroyed => obj
+ case _ => player
+ }
+ val (pos, ori) = spawnPoint.SpecificPoint(obj)
+ if (zoningType == Zoning.Method.InstantAction)
+ LoadZonePhysicalSpawnPoint(zone.id, pos, ori, respawnTime = 0 seconds, Some(spawnPoint))
+ else
+ LoadZonePhysicalSpawnPoint(zone.id, pos, ori, CountSpawnDelay(zone.id, spawnPoint, continent.id), Some(spawnPoint))
+ case None =>
+ log.warn(
+ s"SpawnPointResponse: ${player.Name} received no spawn point response when asking InterstellarClusterService"
+ )
+ if (Config.app.game.warpGates.defaultToSanctuaryDestination) {
+ log.warn(s"SpawnPointResponse: sending ${player.Name} home")
+ RequestSanctuaryZoneSpawn(player, currentZone = 0)
+ }
+ }
+ }
+
/**
* Attach the player to a droppod vehicle and hurtle them through the stratosphere in some far off world.
* Perform all normal operation standardization (state cancels) as if any of form of zoning was being performed,
@@ -1900,14 +2014,13 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
/**
* The user no longer expects to perform a zoning event for this reason.
- *
* @param msg the message to the user
* @param msgType the type of message, influencing how it is presented to the user;
- * normally, this message uses the same value as `zoningChatMessageType`s
+ * normally, this message uses the same value as `zoningChatMessageType`;
* defaults to `None`
*/
def CancelZoningProcessWithReason(msg: String, msgType: Option[ChatMessageType] = None): Unit = {
- if (zoningStatus > Zoning.Status.None) {
+ if (zoningStatus != Zoning.Status.None) {
sendResponse(ChatMsg(msgType.getOrElse(zoningChatMessageType), false, "", msg, None))
}
CancelZoningProcess()
@@ -1927,6 +2040,76 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
instantActionFallbackDestination = None
}
+ def HandleNewPlayerLoaded(tplayer: Player): Unit = {
+ //new zone
+ log.info(s"${tplayer.Name} has spawned into ${session.zone.id}")
+ oldRefsMap.clear()
+ persist = UpdatePersistenceAndRefs
+ tplayer.avatar = avatar
+ session = session.copy(player = tplayer)
+ avatarActor ! AvatarActor.CreateImplants()
+ avatarActor ! AvatarActor.InitializeImplants()
+ //LoadMapMessage causes the client to send BeginZoningMessage, eventually leading to SetCurrentAvatar
+ val weaponsEnabled =
+ session.zone.map.name != "map11" && session.zone.map.name != "map12" && session.zone.map.name != "map13"
+ sendResponse(
+ LoadMapMessage(
+ session.zone.map.name,
+ session.zone.id,
+ 40100,
+ 25,
+ weaponsEnabled,
+ session.zone.map.checksum
+ )
+ )
+ if (isAcceptableNextSpawnPoint()) {
+ //important! the LoadMapMessage must be processed by the client before the avatar is created
+ setupAvatarFunc()
+ //interimUngunnedVehicle should have been setup by setupAvatarFunc, if it is applicable
+ turnCounterFunc = interimUngunnedVehicle match {
+ case Some(_) =>
+ TurnCounterDuringInterimWhileInPassengerSeat
+ case None if zoningType == Zoning.Method.Login || zoningType == Zoning.Method.Reset =>
+ TurnCounterLogin
+ case None =>
+ TurnCounterDuringInterim
+ }
+ keepAliveFunc = NormalKeepAlive
+ upstreamMessageCount = 0
+ setAvatar = false
+ persist()
+ } else {
+ //look for different spawn point in same zone
+ cluster ! ICS.GetNearbySpawnPoint(
+ session.zone.Number,
+ tplayer,
+ Seq(SpawnGroup.Facility, SpawnGroup.Tower, SpawnGroup.AMS),
+ context.self
+ )
+ }
+ }
+
+ def HandleZoneResponse(foundZone: Zone): Unit = {
+ log.trace(s"ZoneResponse: zone ${foundZone.id} will now load for ${player.Name}")
+ loadConfZone = true
+ val oldZone = session.zone
+ session = session.copy(zone = foundZone)
+ //the only zone-level event system subscription necessary before BeginZoningMessage (for persistence purposes)
+ continent.AvatarEvents ! Service.Join(player.Name)
+ persist()
+ oldZone.AvatarEvents ! Service.Leave()
+ oldZone.LocalEvents ! Service.Leave()
+ oldZone.VehicleEvents ! Service.Leave()
+ continent.Population ! Zone.Population.Join(avatar)
+ player.avatar = avatar
+ interstellarFerry match {
+ case Some(vehicle) if vehicle.PassengerInSeat(player).contains(0) =>
+ TaskWorkflow.execute(registerDrivenVehicle(vehicle, player))
+ case _ =>
+ TaskWorkflow.execute(registerNewAvatar(player))
+ }
+ }
+
/**
* na
* @param toChannel na
@@ -2110,6 +2293,8 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
} else {
HandleReleaseAvatar(player, continent)
}
+ AvatarActor.savePlayerLocation(player)
+ renewCharSavedTimer(fixedLen=1800L, varLen=0L)
case AvatarResponse.LoadPlayer(pkt) =>
if (tplayer_guid != guid) {
@@ -2274,6 +2459,14 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
case AvatarResponse.TerminalOrderResult(terminal_guid, action, result) =>
sendResponse(ItemTransactionResultMessage(terminal_guid, action, result))
lastTerminalOrderFulfillment = true
+ if (result &&
+ (action == TransactionType.Buy || action == TransactionType.Loadout)) {
+ AvatarActor.savePlayerData(player)
+ renewCharSavedTimer(
+ Config.app.game.savedMsg.interruptedByAction.fixed,
+ Config.app.game.savedMsg.interruptedByAction.variable
+ )
+ }
case AvatarResponse.ChangeExosuit(
target,
@@ -3534,6 +3727,11 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
//killed during spawn setup or possibly a relog into a corpse (by accident?)
player.Actor ! Player.Die()
}
+ AvatarActor.savePlayerData(player)
+ displayCharSavedMsgThenRenewTimer(
+ Config.app.game.savedMsg.short.fixed,
+ Config.app.game.savedMsg.short.variable
+ )
upstreamMessageCount = 0
setAvatar = true
}
@@ -5603,10 +5801,16 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
}
if (action == 29) {
log.info(s"${player.Name} is AFK")
+ AvatarActor.savePlayerLocation(player)
+ displayCharSavedMsgThenRenewTimer(fixedLen=1800L, varLen=0L) //~30min
player.AwayFromKeyboard = true
} else if (action == 30) {
log.info(s"${player.Name} is back")
player.AwayFromKeyboard = false
+ renewCharSavedTimer(
+ Config.app.game.savedMsg.renewal.fixed,
+ Config.app.game.savedMsg.renewal.variable
+ )
}
if (action == GenericActionEnum.DropSpecialItem.id) {
DropSpecialSlotItem()
@@ -7311,7 +7515,7 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
* @param tplayer the player to be killed
*/
def suicide(tplayer: Player): Unit = {
- tplayer.History(PlayerSuicide())
+ tplayer.History(PlayerSuicide(PlayerSource(tplayer)))
tplayer.Actor ! Player.Die()
}
@@ -8535,7 +8739,6 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
* If the player is alive and mounted in a vehicle, a different can of worms is produced.
* The ramifications of these conditions are not fully satisfied until the player loads into the new zone.
* Even then, the conclusion becomes delayed while a slightly lagged mechanism hoists players between zones.
- *
* @param zoneId the zone in which the player will be placed
* @param pos the game world coordinates where the player will be positioned
* @param ori the direction in which the player will be oriented
@@ -8598,7 +8801,6 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
}
}
}
-
}
/**
@@ -8630,15 +8832,15 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
session = session.copy(player = targetPlayer)
TaskWorkflow.execute(taskThenZoneChange(
GUIDTask.unregisterObject(continent.GUID, original.avatar.locker),
- ICS.FindZone(_.id == zoneId, context.self)
+ ICS.FindZone(_.id.equals(zoneId), context.self)
))
} else if (player.HasGUID) {
TaskWorkflow.execute(taskThenZoneChange(
GUIDTask.unregisterAvatar(continent.GUID, original),
- ICS.FindZone(_.id == zoneId, context.self)
+ ICS.FindZone(_.id.equals(zoneId), context.self)
))
} else {
- cluster ! ICS.FindZone(_.id == zoneId, context.self)
+ cluster ! ICS.FindZone(_.id.equals(zoneId), context.self)
}
}
@@ -9372,6 +9574,22 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
turnCounterFunc = NormalTurnCounter
}
}
+ /**
+ * The upstream counter accumulates when the server receives specific messages from the client.
+ *
+ * This accumulator is assigned after a login event.
+ * The main purpose is to display any messages to the client regarding
+ * if their previous log-out location and their current log-in location are different.
+ * Hereafter, the normal accumulator will be referenced.
+ * @param guid the player's globally unique identifier number
+ */
+ def TurnCounterLogin(guid: PlanetSideGUID): Unit = {
+ NormalTurnCounter(guid)
+ sendResponse(ChatMsg(zoningChatMessageType, false, "", loginChatMessage, None))
+ CancelZoningProcess()
+ loginChatMessage = ""
+ turnCounterFunc = NormalTurnCounter
+ }
/**
* The normal response to receiving a `KeepAliveMessage` packet from the client.
@@ -9853,6 +10071,21 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
cluster ! ICS.FilterZones(_ => true, context.self)
}
+ def displayCharSavedMsgThenRenewTimer(fixedLen: Long, varLen: Long): Unit = {
+ charSaved()
+ renewCharSavedTimer(fixedLen, varLen)
+ }
+
+ def renewCharSavedTimer(fixedLen: Long, varLen: Long): Unit = {
+ charSavedTimer.cancel()
+ val delay = (fixedLen + (varLen * scala.math.random()).toInt).seconds
+ charSavedTimer = context.system.scheduler.scheduleOnce(delay, self, SessionActor.CharSavedMsg)
+ }
+
+ def charSaved(): Unit = {
+ sendResponse(ChatMsg(ChatMessageType.UNK_227, wideContents=false, "", "@charsaved", None))
+ }
+
def failWithError(error: String) = {
log.error(error)
middlewareActor ! MiddlewareActor.Teardown()
diff --git a/src/main/scala/net/psforever/objects/Player.scala b/src/main/scala/net/psforever/objects/Player.scala
index 8a98866c..8e5e4242 100644
--- a/src/main/scala/net/psforever/objects/Player.scala
+++ b/src/main/scala/net/psforever/objects/Player.scala
@@ -62,7 +62,7 @@ class Player(var avatar: Avatar)
private var jumping: Boolean = false
private var cloaked: Boolean = false
private var afk: Boolean = false
- private var zoning: Zoning.Method.Value = Zoning.Method.None
+ private var zoning: Zoning.Method = Zoning.Method.None
private var vehicleSeated: Option[PlanetSideGUID] = None
@@ -526,9 +526,9 @@ class Player(var avatar: Avatar)
Carrying
}
- def ZoningRequest: Zoning.Method.Value = zoning
+ def ZoningRequest: Zoning.Method = zoning
- def ZoningRequest_=(request: Zoning.Method.Value): Zoning.Method.Value = {
+ def ZoningRequest_=(request: Zoning.Method): Zoning.Method = {
zoning = request
ZoningRequest
}
diff --git a/src/main/scala/net/psforever/objects/Session.scala b/src/main/scala/net/psforever/objects/Session.scala
index 9a039c80..06b5d6d9 100644
--- a/src/main/scala/net/psforever/objects/Session.scala
+++ b/src/main/scala/net/psforever/objects/Session.scala
@@ -10,7 +10,7 @@ case class Session(
account: Account = null,
player: Player = null,
avatar: Avatar = null,
- zoningType: Zoning.Method.Value = Zoning.Method.None,
+ zoningType: Zoning.Method = Zoning.Method.None,
deadState: DeadState.Value = DeadState.Alive,
speed: Float = 1.0f,
flying: Boolean = false
diff --git a/src/main/scala/net/psforever/objects/vital/VitalsHistory.scala b/src/main/scala/net/psforever/objects/vital/VitalsHistory.scala
index ff09d540..2255089c 100644
--- a/src/main/scala/net/psforever/objects/vital/VitalsHistory.scala
+++ b/src/main/scala/net/psforever/objects/vital/VitalsHistory.scala
@@ -1,12 +1,13 @@
// Copyright (c) 2020 PSForever
package net.psforever.objects.vital
+import net.psforever.objects.Player
import net.psforever.objects.ballistics.{PlayerSource, VehicleSource}
import net.psforever.objects.definition.{EquipmentDefinition, KitDefinition, ToolDefinition}
import net.psforever.objects.serverobject.terminals.TerminalDefinition
import net.psforever.objects.vital.environment.EnvironmentReason
-import net.psforever.objects.vital.etc.{ExplodingEntityReason, PainboxReason}
-import net.psforever.objects.vital.interaction.DamageResult
+import net.psforever.objects.vital.etc.{ExplodingEntityReason, PainboxReason, SuicideReason}
+import net.psforever.objects.vital.interaction.{DamageInteraction, DamageResult}
import net.psforever.objects.vital.projectile.ProjectileReason
import net.psforever.types.{ExoSuitType, ImplantType}
@@ -15,6 +16,7 @@ trait VitalsActivity {
}
trait HealingActivity extends VitalsActivity {
+ def amount: Int
val time: Long = System.currentTimeMillis()
}
@@ -33,13 +35,19 @@ final case class HealFromEquipment(
) extends HealingActivity
final case class HealFromTerm(term_def: TerminalDefinition, health: Int, armor: Int)
- extends HealingActivity
+ extends HealingActivity {
+ def amount: Int = health + armor
+}
final case class HealFromImplant(implant: ImplantType, health: Int)
- extends HealingActivity
+ extends HealingActivity {
+ def amount: Int = health
+}
final case class HealFromExoSuitChange(exosuit: ExoSuitType.Value)
- extends HealingActivity
+ extends HealingActivity {
+ def amount: Int = 0
+}
final case class RepairFromKit(kit_def: KitDefinition, amount: Int)
extends HealingActivity()
@@ -71,9 +79,17 @@ final case class DamageFromPainbox(data: DamageResult)
final case class DamageFromEnvironment(data: DamageResult)
extends DamagingActivity
-final case class PlayerSuicide()
+final case class PlayerSuicide(player: PlayerSource)
extends DamagingActivity {
- def data: DamageResult = null //TODO do something
+ private lazy val result = {
+ val out = DamageResult(
+ player,
+ player.copy(health = 0),
+ DamageInteraction(player, SuicideReason(), player.Position)
+ )
+ out
+ }
+ def data: DamageResult = result
}
final case class DamageFromExplodingEntity(data: DamageResult)
diff --git a/src/main/scala/net/psforever/objects/zones/Zoning.scala b/src/main/scala/net/psforever/objects/zones/Zoning.scala
index 16848a0b..6d9bcb7f 100644
--- a/src/main/scala/net/psforever/objects/zones/Zoning.scala
+++ b/src/main/scala/net/psforever/objects/zones/Zoning.scala
@@ -1,19 +1,32 @@
+// Copyright (c) 2020 PSForever
package net.psforever.objects.zones
+import enumeratum.values.{StringEnum, StringEnumEntry}
import net.psforever.objects.SpawnPoint
import net.psforever.types.{PlanetSideEmpire, Vector3}
object Zoning {
- object Method extends Enumeration {
- type Type = Value
+ sealed abstract class Method(val value: String) extends StringEnumEntry
+ sealed abstract class Status(val value: String) extends StringEnumEntry
- val None, InstantAction, OutfitRecall, Recall, Quit = Value
+ object Method extends StringEnum[Method] {
+ val values: IndexedSeq[Method] = findValues
+
+ case object None extends Method(value = "None")
+ case object InstantAction extends Method(value = "InstantAction")
+ case object OutfitRecall extends Method(value = "OutfitRecall")
+ case object Recall extends Method(value = "Recall")
+ case object Quit extends Method(value = "Quit")
+ case object Login extends Method(value = "Login")
+ case object Reset extends Method(value = "Reset")
}
- object Status extends Enumeration {
- type Type = Value
+ object Status extends StringEnum[Status] {
+ val values: IndexedSeq[Status] = findValues
- val None, Request, Countdown = Value
+ case object None extends Status(value = "None")
+ case object Request extends Status(value = "Request")
+ case object Countdown extends Status(value = "Countdown")
}
object Time {
diff --git a/src/main/scala/net/psforever/persistence/Account.scala b/src/main/scala/net/psforever/persistence/Account.scala
index dc7e6b79..53841b45 100644
--- a/src/main/scala/net/psforever/persistence/Account.scala
+++ b/src/main/scala/net/psforever/persistence/Account.scala
@@ -9,5 +9,6 @@ case class Account(
created: LocalDateTime = LocalDateTime.now(),
lastModified: LocalDateTime = LocalDateTime.now(),
inactive: Boolean = false,
- gm: Boolean = false
+ gm: Boolean = false,
+ lastFactionId: Int = 3
)
diff --git a/src/main/scala/net/psforever/persistence/Acquaintance.scala b/src/main/scala/net/psforever/persistence/Acquaintance.scala
index 3a17784f..1bbe4abe 100644
--- a/src/main/scala/net/psforever/persistence/Acquaintance.scala
+++ b/src/main/scala/net/psforever/persistence/Acquaintance.scala
@@ -1,6 +1,6 @@
// Copyright (c) 2022 PSForever
package net.psforever.persistence
-case class Friend(id: Int, avatarId: Long, charId: Long)
+case class Friend(avatarId: Long, charId: Long)
-case class Ignored(id: Int, avatarId: Long, charId: Long)
+case class Ignored(avatarId: Long, charId: Long)
diff --git a/src/main/scala/net/psforever/persistence/SavedCharacter.scala b/src/main/scala/net/psforever/persistence/SavedCharacter.scala
new file mode 100644
index 00000000..23f57c7f
--- /dev/null
+++ b/src/main/scala/net/psforever/persistence/SavedCharacter.scala
@@ -0,0 +1,24 @@
+// Copyright (c) 2022 PSForever
+package net.psforever.persistence
+
+import org.joda.time.LocalDateTime
+
+case class Savedplayer(
+ avatarId: Long,
+ px: Int, //Position.x * 1000
+ py: Int, //Position.y * 1000
+ pz: Int, //Position.z * 1000
+ orientation: Int, //Orientation.z * 1000
+ zoneNum: Int,
+ health: Int,
+ armor: Int,
+ exosuitNum: Int,
+ loadout: String
+ )
+
+case class Savedavatar(
+ avatarId: Long,
+ forgetCooldown: LocalDateTime,
+ purchaseCooldowns: String,
+ useCooldowns: String
+ )
diff --git a/src/main/scala/net/psforever/services/account/AccountPersistenceService.scala b/src/main/scala/net/psforever/services/account/AccountPersistenceService.scala
index 4ef85183..4441cfb4 100644
--- a/src/main/scala/net/psforever/services/account/AccountPersistenceService.scala
+++ b/src/main/scala/net/psforever/services/account/AccountPersistenceService.scala
@@ -2,6 +2,7 @@
package net.psforever.services.account
import akka.actor.{Actor, ActorRef, Cancellable, Props}
+import net.psforever.actors.session.AvatarActor
import scala.collection.mutable
import scala.concurrent.duration._
@@ -11,10 +12,14 @@ import net.psforever.objects._
import net.psforever.objects.avatar.Avatar
import net.psforever.objects.serverobject.mount.Mountable
import net.psforever.objects.zones.Zone
+import net.psforever.persistence
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}
+import net.psforever.zones.Zones
+
+import scala.util.Success
/**
* A global service that manages user behavior as divided into the following three categories:
@@ -79,7 +84,7 @@ class AccountPersistenceService extends Actor {
* but, updating should be reserved for individual persistence monitor callback (by the user who is being monitored).
*/
val Started: Receive = {
- case msg @ AccountPersistenceService.Login(name) =>
+ case msg @ AccountPersistenceService.Login(name, _) =>
(accounts.get(name) match {
case Some(ref) => ref
case None => CreateNewPlayerToken(name)
@@ -189,7 +194,7 @@ object AccountPersistenceService {
* If the persistence monitor already exists, use that instead and synchronize the data.
* @param name the unique name of the player
*/
- final case class Login(name: String)
+ final case class Login(name: String, charId: Long)
/**
* Update the persistence monitor that was setup for a user with the given text descriptor (player name).
@@ -269,13 +274,31 @@ class PersistenceMonitor(
}
def receive: Receive = {
- case AccountPersistenceService.Login(_) =>
- sender() ! (if (kicked) {
- PlayerToken.CanNotLogin(name, PlayerToken.DeniedLoginReason.Kicked)
- } else {
- UpdateTimer()
- PlayerToken.LoginInfo(name, inZone, lastPosition)
- })
+ case AccountPersistenceService.Login(_, charId) =>
+ UpdateTimer() //longer!
+ if (kicked) {
+ //persistence hasn't ended yet, but we were kicked out of the game
+ sender() ! PlayerToken.CanNotLogin(name, PlayerToken.DeniedLoginReason.Kicked)
+ } else {
+ if (inZone != Zone.Nowhere) {
+ //persistence hasn't ended yet
+ sender() ! PlayerToken.RestoreInfo(name, inZone, lastPosition)
+ } else {
+ val replyTo = sender()
+ //proper login; what was our last position according to the database?
+ AvatarActor.loadSavedPlayerData(charId).onComplete {
+ case Success(results) =>
+ Zones.zones.find(zone => zone.Number == results.zoneNum) match {
+ case Some(zone) =>
+ replyTo ! PlayerToken.LoginInfo(name, zone, Some(results))
+ case _ =>
+ replyTo ! PlayerToken.LoginInfo(name, inZone, None)
+ }
+ case _ =>
+ replyTo ! PlayerToken.LoginInfo(name, inZone, None)
+ }
+ }
+ }
case AccountPersistenceService.Update(_, z, p) if !kicked =>
inZone = z
@@ -344,7 +367,8 @@ class PersistenceMonitor(
case (Some(avatar), Some(player)) if player.VehicleSeated.nonEmpty =>
//alive or dead in a vehicle
//if the avatar is dead while in a vehicle, they haven't released yet
- //TODO perform any last minute saving now ...
+ AvatarActor.saveAvatarData(avatar)
+ AvatarActor.finalSavePlayerData(player)
(inZone.GUID(player.VehicleSeated) match {
case Some(obj: Mountable) =>
(Some(obj), obj.Seat(obj.PassengerInSeat(player).getOrElse(-1)))
@@ -358,13 +382,14 @@ class PersistenceMonitor(
case (Some(avatar), Some(player)) =>
//alive or dead, as standard Infantry
- //TODO perform any last minute saving now ...
+ AvatarActor.saveAvatarData(avatar)
+ AvatarActor.finalSavePlayerData(player)
PlayerAvatarLogout(avatar, player)
case (Some(avatar), None) =>
//player has released
//our last body was turned into a corpse; just the avatar remains
- //TODO perform any last minute saving now ...
+ AvatarActor.saveAvatarData(avatar)
inZone.GUID(avatar.vehicle) match {
case Some(obj: Vehicle) if obj.OwnerName.contains(avatar.name) =>
obj.Actor ! Vehicle.Ownership(None)
@@ -373,7 +398,7 @@ class PersistenceMonitor(
AvatarLogout(avatar)
case _ =>
- //user stalled during initial session, or was caught in between zone transfer
+ //user stalled during initial session, or was caught in between zone transfer
}
}
@@ -451,9 +476,22 @@ object PlayerToken {
* ("Exists" does not imply an ongoing process and can also mean "just joined the game" here.)
* @param name the name of the player
* @param zone the zone in which the player is location
+ * @param optionalSavedData additional information about the last time the player was in the game
+ */
+ final case class LoginInfo(name: String, zone: Zone, optionalSavedData: Option[persistence.Savedplayer])
+
+ /**
+ * ...
+ * @param name the name of the player
+ * @param zone the zone in which the player is location
* @param position where in the zone the player is located
*/
- final case class LoginInfo(name: String, zone: Zone, position: Vector3)
+ final case class RestoreInfo(name: String, zone: Zone, position: Vector3)
+ /**
+ * ...
+ * @param name the name of the player
+ * @param reason why the player can not log into the game
+ */
final case class CanNotLogin(name: String, reason: DeniedLoginReason.Value)
}
diff --git a/src/main/scala/net/psforever/util/Config.scala b/src/main/scala/net/psforever/util/Config.scala
index 1bcde9a4..bd2d1c71 100644
--- a/src/main/scala/net/psforever/util/Config.scala
+++ b/src/main/scala/net/psforever/util/Config.scala
@@ -158,7 +158,8 @@ case class GameConfig(
sharedMaxCooldown: Boolean,
baseCertifications: Seq[Certification],
warpGates: WarpGateConfig,
- cavernRotation: CavernRotationConfig
+ cavernRotation: CavernRotationConfig,
+ savedMsg: SavedMessageEvents
)
case class NewAvatar(
@@ -204,3 +205,14 @@ case class CavernRotationConfig(
enhancedRotationOrder: Seq[Int],
forceRotationImmediately: Boolean
)
+
+case class SavedMessageEvents(
+ short: SavedMessageTimings,
+ renewal: SavedMessageTimings,
+ interruptedByAction: SavedMessageTimings
+)
+
+case class SavedMessageTimings(
+ fixed: Long,
+ variable: Long
+)
diff --git a/src/test/scala/objects/VitalityTest.scala b/src/test/scala/objects/VitalityTest.scala
index da8a2408..9186503b 100644
--- a/src/test/scala/objects/VitalityTest.scala
+++ b/src/test/scala/objects/VitalityTest.scala
@@ -42,7 +42,7 @@ class VitalityTest extends Specification {
player.History(HealFromExoSuitChange(pSource, ExoSuitType.Standard))
player.History(RepairFromTerm(vSource, 10, GlobalDefinitions.order_terminal))
player.History(VehicleShieldCharge(vSource, 10))
- player.History(PlayerSuicide())
+ player.History(PlayerSuicide(PlayerSource(player)))
ok
}
@@ -56,7 +56,7 @@ class VitalityTest extends Specification {
player.History(HealFromExoSuitChange(pSource, ExoSuitType.Standard))
player.History(RepairFromTerm(vSource, 10, GlobalDefinitions.order_terminal))
player.History(VehicleShieldCharge(vSource, 10))
- player.History(PlayerSuicide())
+ player.History(PlayerSuicide(PlayerSource(player)))
player.History.size mustEqual 7
val list = player.ClearHistory()
@@ -92,7 +92,7 @@ class VitalityTest extends Specification {
player.History(HealFromExoSuitChange(pSource, ExoSuitType.Standard))
player.History(RepairFromTerm(vSource, 10, GlobalDefinitions.order_terminal))
player.History(VehicleShieldCharge(vSource, 10))
- player.History(PlayerSuicide())
+ player.History(PlayerSuicide(PlayerSource(player)))
player.LastShot match {
case Some(resolved_projectile) =>