From 1369da22f062a75ffc9f4cec2f3d87383dd7b2f3 Mon Sep 17 00:00:00 2001 From: Fate-JH Date: Tue, 11 Oct 2022 11:16:12 -0400 Subject: [PATCH] Login Location Persistence (#1009) * database tables and persistence entities; attempt to retrieve values from database and apply them to the player avatar character; resolve spawn options in sanctuary in different manner * minor database table field adjustments; saving to database when account persistence ends; properly loading from and initializing with data queried from the database; suicide better supported * converting the zoning method/status tokens; better support of zoning handling of persistent locations; messages that explain the consequences of login-spawning into an invalid location * adding triggers for the 'saved char' message, both those prompted by game activity and a 'reassurance' message; also, actually save the char data sometimes * intervals for timing charsaved message set by configuration file now * corrections to spawn tests and tables * random sanctuary spawn in more places than one --- .../db/migration/V005__Acquaintances.sql | 8 +- .../db/migration/V006__SavedCharacters.sql | 22 + src/main/resources/application.conf | 30 ++ .../actors/session/AvatarActor.scala | 406 +++++++++++++-- .../actors/session/SessionActor.scala | 467 +++++++++++++----- .../scala/net/psforever/objects/Player.scala | 6 +- .../scala/net/psforever/objects/Session.scala | 2 +- .../objects/vital/VitalsHistory.scala | 30 +- .../net/psforever/objects/zones/Zoning.scala | 25 +- .../net/psforever/persistence/Account.scala | 3 +- .../psforever/persistence/Acquaintance.scala | 4 +- .../persistence/SavedCharacter.scala | 24 + .../account/AccountPersistenceService.scala | 66 ++- .../scala/net/psforever/util/Config.scala | 14 +- src/test/scala/objects/VitalityTest.scala | 6 +- 15 files changed, 918 insertions(+), 195 deletions(-) create mode 100644 server/src/main/resources/db/migration/V006__SavedCharacters.sql create mode 100644 src/main/scala/net/psforever/persistence/SavedCharacter.scala 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) =>