diff --git a/server/src/main/resources/db/migration/V004__VehicleLoadout.sql b/server/src/main/resources/db/migration/V004__VehicleLoadout.sql new file mode 100644 index 000000000..fad67b456 --- /dev/null +++ b/server/src/main/resources/db/migration/V004__VehicleLoadout.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS "vehicleloadout" ( + "id" SERIAL PRIMARY KEY NOT NULL, + "avatar_id" INT NOT NULL REFERENCES avatar (id), + "loadout_number" INT NOT NULL, + "name" VARCHAR(36) NOT NULL, + "vehicle" SMALLINT NOT NULL, + "items" TEXT NOT NULL +); diff --git a/src/main/scala/net/psforever/actors/session/AvatarActor.scala b/src/main/scala/net/psforever/actors/session/AvatarActor.scala index 7ce7ef5a8..bc5dcb31e 100644 --- a/src/main/scala/net/psforever/actors/session/AvatarActor.scala +++ b/src/main/scala/net/psforever/actors/session/AvatarActor.scala @@ -8,9 +8,10 @@ import akka.actor.typed.{ActorRef, Behavior, PostStop, SupervisorStrategy} import net.psforever.objects.avatar._ import net.psforever.objects.definition.converter.CharacterSelectConverter import net.psforever.objects.definition._ -import net.psforever.objects.equipment.Equipment -import net.psforever.objects.inventory.{Container, InventoryItem} -import net.psforever.objects.loadouts.{InfantryLoadout, Loadout} +import net.psforever.objects.inventory.Container +import net.psforever.objects.equipment.{Equipment, EquipmentSlot} +import net.psforever.objects.inventory.InventoryItem +import net.psforever.objects.loadouts.{InfantryLoadout, Loadout, VehicleLoadout} import net.psforever.objects._ import net.psforever.objects.locker.LockerContainer import net.psforever.packet.game.objectcreate.ObjectClass @@ -96,7 +97,10 @@ object AvatarActor { /** Delete a loadout */ final case class DeleteLoadout(player: Player, loadoutType: LoadoutType.Value, number: Int) extends Command - /** Refresh the client's loadouts */ + /** Refresh the client's loadouts, excluding empty entries */ + final case class InitialRefreshLoadouts() extends Command + + /** Refresh all of the client's loadouts */ final case class RefreshLoadouts() extends Command /** Take all the entries in the player's locker and write it to the database */ @@ -349,7 +353,7 @@ class AvatarActor( .filter(_.id == lift(avatar.id)) .update(_.lastLogin -> lift(LocalDateTime.now())) ) - loadouts <- loadLoadouts() + loadouts <- initializeAllLoadouts() implants <- ctx.run(query[persistence.Implant].filter(_.avatarId == lift(avatar.id))) certs <- ctx.run(query[persistence.Certification].filter(_.avatarId == lift(avatar.id))) locker <- loadLocker() @@ -374,12 +378,7 @@ class AvatarActor( Behaviors.same case ReplaceAvatar(newAvatar) => - avatar = newAvatar - avatar.deployables.UpdateMaxCounts(avatar.certifications) - updateDeployableUIElements( - avatar.deployables.UpdateUI() - ) - + replaceAvatar(newAvatar) Behaviors.same case AddFirstTimeEvent(event) => @@ -434,7 +433,7 @@ class AvatarActor( sessionActor ! SessionActor.SendResponse( PlanetsideAttributeMessage(session.get.player.GUID, 24, certification.value) ) - context.self ! ReplaceAvatar( + replaceAvatar( avatar.copy(certifications = avatar.certifications.diff(replace) + certification) ) sessionActor ! SessionActor.SendResponse( @@ -487,7 +486,7 @@ class AvatarActor( ) case Success(certs) => val player = session.get.player - context.self ! ReplaceAvatar(avatar.copy(certifications = avatar.certifications.diff(certs))) + replaceAvatar(avatar.copy(certifications = avatar.certifications.diff(certs))) certs.foreach { cert => sessionActor ! SessionActor.SendResponse( PlanetsideAttributeMessage(player.GUID, 25, cert.value) @@ -548,7 +547,7 @@ class AvatarActor( ) .onComplete { case Success(_) => - context.self ! ReplaceAvatar(avatar.copy(certifications = certifications)) + replaceAvatar(avatar.copy(certifications = certifications)) case Failure(exception) => log.error(exception)("db failure") } @@ -584,9 +583,7 @@ class AvatarActor( .run(query[persistence.Implant].insert(_.name -> lift(definition.Name), _.avatarId -> lift(avatar.id))) .onComplete { case Success(_) => - context.self ! ReplaceAvatar( - avatar.copy(implants = avatar.implants.updated(_index, Some(Implant(definition)))) - ) + replaceAvatar(avatar.copy(implants = avatar.implants.updated(_index, Some(Implant(definition))))) sessionActor ! SessionActor.SendResponse( AvatarImplantMessage( session.get.player.GUID, @@ -627,7 +624,7 @@ class AvatarActor( ) .onComplete { case Success(_) => - context.self ! ReplaceAvatar(avatar.copy(implants = avatar.implants.updated(_index, None))) + replaceAvatar(avatar.copy(implants = avatar.implants.updated(_index, None))) sessionActor ! SessionActor.SendResponse( AvatarImplantMessage(session.get.player.GUID, ImplantAction.Remove, _index, 0) ) @@ -647,59 +644,69 @@ class AvatarActor( Behaviors.same case SaveLoadout(player, loadoutType, label, number) => + log.info(s"${player.Name} wishes to save a favorite $loadoutType loadout as #${number+1}") val name = label.getOrElse(s"missing_loadout_${number + 1}") - loadoutType match { + val (lineNo, result): (Int, Future[Loadout]) = loadoutType match { case LoadoutType.Infantry => - storeLoadout(player, name, number).onComplete { - case Success(_) => - loadLoadouts().onComplete { - case Success(loadouts) => - context.self ! ReplaceAvatar(avatar.copy(loadouts = loadouts)) - context.self ! RefreshLoadouts() - case Failure(exception) => log.error(exception)("db failure") - } - - case Failure(exception) => log.error(exception)("db failure") - } + ( + number, + storeLoadout(player, name, number) + ) case LoadoutType.Vehicle => - // TODO - // storeLoadout(player, name, 10 + number) - sessionActor ! SessionActor.SendResponse(FavoritesMessage(loadoutType, player.GUID, number, name)) + ( + number + 10, + player.Zone.GUID(avatar.vehicle) match { + case Some(vehicle: Vehicle) => + storeVehicleLoadout(player, name, number, vehicle) + case _ => + throwLoadoutFailure(s"no owned vehicle found for ${player.Name}") + } + ) + } + result.onComplete { + case Success(loadout) => + replaceAvatar(avatar.copy(loadouts = avatar.loadouts.updated(lineNo, Some(loadout)))) + refreshLoadout(lineNo) + case Failure(exception) => + log.error(exception)("db failure (?)") } Behaviors.same case DeleteLoadout(player, loadoutType, number) => + log.info(s"${player.Name} wishes to delete a favorite $loadoutType loadout - #${number+1}") import ctx._ - ctx - .run( - query[persistence.Loadout] - .filter(_.avatarId == lift(avatar.id)) - .filter(_.loadoutNumber == lift(number)) - .delete - ) - .onComplete { - case Success(_) => - context.self ! ReplaceAvatar(avatar.copy(loadouts = avatar.loadouts.updated(number, None))) - sessionActor ! SessionActor.SendResponse(FavoritesMessage(loadoutType, player.GUID, number, "")) - case Failure(exception) => - log.error(exception)("db failure") - } - Behaviors.same - - case RefreshLoadouts() => - avatar.loadouts.zipWithIndex.foreach { - case (Some(loadout: InfantryLoadout), index) => - sessionActor ! SessionActor.SendResponse( - FavoritesMessage( - LoadoutType.Infantry, - session.get.player.GUID, - index, - loadout.label, - InfantryLoadout.DetermineSubtypeB(loadout.exosuit, loadout.subtype) + val (lineNo, result) = loadoutType match { + case LoadoutType.Infantry if avatar.loadouts(number).nonEmpty => + ( + number, + ctx.run( + query[persistence.Loadout] + .filter(_.avatarId == lift(avatar.id)) + .filter(_.loadoutNumber == lift(number)) + .delete ) ) - case _ => ; + case LoadoutType.Vehicle if avatar.loadouts(number + 10).nonEmpty => + val lineNo = number + 10 + ( + lineNo, + ctx.run( + query[persistence.Vehicleloadout] + .filter(_.avatarId == lift(avatar.id)) + .filter(_.loadoutNumber == lift(number)) + .delete + ) + ) + case _ => + (number, throwLoadoutFailure("unhandled loadout type or no loadout")) + } + result.onComplete { + case Success(_) => + replaceAvatar(avatar.copy(loadouts = avatar.loadouts.updated(lineNo, None))) + sessionActor ! SessionActor.SendResponse(FavoritesMessage(loadoutType, player.GUID, number, "")) + case Failure(exception) => + log.error(exception)("db failure (?)") } Behaviors.same @@ -707,6 +714,14 @@ class AvatarActor( saveLockerFunc() Behaviors.same + case InitialRefreshLoadouts() => + refreshLoadouts(avatar.loadouts.zipWithIndex) + Behaviors.same + + case RefreshLoadouts() => + refreshLoadouts(avatar.loadouts.zipWithIndex.collect { case out @ (Some(_), _) => out }) + Behaviors.same + case UpdatePurchaseTime(definition, time) => // TODO save to db var newTimes = avatar.purchaseTimes @@ -955,6 +970,22 @@ class AvatarActor( } } + def throwLoadoutFailure(msg: String): Future[Loadout] = { + throwLoadoutFailure(new Exception(msg)) + } + + def throwLoadoutFailure(ex: Throwable): Future[Loadout] = { + Future.failed(ex).asInstanceOf[Future[Loadout]] + } + + def replaceAvatar(newAvatar: Avatar): Unit = { + avatar = newAvatar + avatar.deployables.UpdateMaxCounts(avatar.certifications) + updateDeployableUIElements( + avatar.deployables.UpdateUI() + ) + } + def setCosmetics(cosmetics: Set[Cosmetic]): Future[Unit] = { val p = Promise[Unit]() @@ -1183,9 +1214,8 @@ class AvatarActor( } } - def storeLoadout(owner: Player, label: String, line: Int): Future[Unit] = { + def storeLoadout(owner: Player, label: String, line: Int): Future[Loadout] = { import ctx._ - val items: String = { val clobber: StringBuilder = new StringBuilder() //encode holsters @@ -1226,7 +1256,53 @@ class AvatarActor( ) ) } - } yield () + } yield Loadout.Create(owner, label) + } + + def storeVehicleLoadout(owner: Player, label: String, line: Int, vehicle: Vehicle): Future[Loadout] = { + import ctx._ + val items: String = { + val clobber: StringBuilder = new StringBuilder() + //encode holsters + vehicle + .Weapons + .collect { + case (index, slot: EquipmentSlot) if slot.Equipment.nonEmpty => + clobber.append(encodeLoadoutClobFragment(slot.Equipment.get, index)) + } + //encode inventory + vehicle.Inventory.Items.foreach { + case InventoryItem(obj, index) => + clobber.append(encodeLoadoutClobFragment(obj, index)) + } + clobber.mkString.drop(1) + } + + for { + loadouts <- ctx.run( + query[persistence.Vehicleloadout] + .filter(_.avatarId == lift(owner.CharId)) + .filter(_.loadoutNumber == lift(line)) + ) + _ <- loadouts.headOption match { + case Some(loadout) => + ctx.run( + query[persistence.Vehicleloadout] + .filter(_.id == lift(loadout.id)) + .update(_.name -> lift(label), _.vehicle -> lift(vehicle.Definition.ObjectId), _.items -> lift(items)) + ) + case None => + ctx.run( + query[persistence.Vehicleloadout].insert( + _.avatarId -> lift(owner.avatar.id), + _.loadoutNumber -> lift(line), + _.name -> lift(label), + _.vehicle -> lift(vehicle.Definition.ObjectId), + _.items -> lift(items) + ) + ) + } + } yield Loadout.Create(vehicle, label) } def storeNewLocker(): Unit = { @@ -1293,6 +1369,19 @@ class AvatarActor( s"/${equipment.getClass.getSimpleName},$index,${equipment.Definition.ObjectId},$ammoInfo" } + def initializeAllLoadouts(): Future[Seq[Option[Loadout]]] = { + for { + infantry <- loadLoadouts().andThen { + case out @ Success(_) => out + case Failure(_) => Future(Array.fill[Option[Loadout]](10)(None).toSeq) + } + vehicles <- loadVehicleLoadouts().andThen { + case out @ Success(_) => out + case Failure(_) => Future(Array.fill[Option[Loadout]](5)(None).toSeq) + } + } yield infantry ++ vehicles + } + def loadLoadouts(): Future[Seq[Option[Loadout]]] = { import ctx._ ctx @@ -1311,7 +1400,102 @@ class AvatarActor( result } } - .map { loadouts => (0 until 15).map { index => loadouts.find(_._1 == index).map(_._2) } } + .map { loadouts => (0 until 10).map { index => loadouts.find(_._1 == index).map(_._2) } } + } + + def loadVehicleLoadouts(): Future[Seq[Option[Loadout]]] = { + import ctx._ + ctx + .run(query[persistence.Vehicleloadout].filter(_.avatarId == lift(avatar.id))) + .map { loadouts => + loadouts.map { loadout => + val toy = new Vehicle(DefinitionUtil.idToDefinition(loadout.vehicle).asInstanceOf[VehicleDefinition]) + buildContainedEquipmentFromClob(toy, loadout.items) + + val result = (loadout.loadoutNumber, Loadout.Create(toy, loadout.name)) + toy.Weapons.values.foreach(slot => { + slot.Equipment = None + }) + toy.Inventory.Clear() + result + } + } + .map { loadouts => (0 until 5).map { index => loadouts.find(_._1 == index).map(_._2) } } + } + + def refreshLoadouts(loadouts: Iterable[(Option[Loadout], Int)]): Unit = { + loadouts.map { + case (Some(loadout: InfantryLoadout), index) => + FavoritesMessage( + LoadoutType.Infantry, + session.get.player.GUID, + index, + loadout.label, + InfantryLoadout.DetermineSubtypeB(loadout.exosuit, loadout.subtype) + ) + case (Some(loadout: VehicleLoadout), index) => + FavoritesMessage( + LoadoutType.Vehicle, + session.get.player.GUID, + index - 10, + loadout.label, + 0 + ) + case (_, index) => + val (mtype, lineNo) = if (index < 10) { + (LoadoutType.Infantry, index) + } else { + (LoadoutType.Vehicle, index - 10) + } + FavoritesMessage( + mtype, + session.get.player.GUID, + lineNo, + "", + 0 + ) + }.foreach { sessionActor ! SessionActor.SendResponse(_) } + } + + def refreshLoadout(line: Int): Unit = { + avatar.loadouts.lift(line) match { + case Some(Some(loadout: InfantryLoadout)) => + sessionActor ! SessionActor.SendResponse( + FavoritesMessage( + LoadoutType.Infantry, + session.get.player.GUID, + line, + loadout.label, + InfantryLoadout.DetermineSubtypeB(loadout.exosuit, loadout.subtype) + ) + ) + case Some(Some(loadout: VehicleLoadout)) => + sessionActor ! SessionActor.SendResponse( + FavoritesMessage( + LoadoutType.Vehicle, + session.get.player.GUID, + line - 10, + loadout.label, + 0 + ) + ) + case Some(None) => + val (mtype, lineNo) = if (line < 10) { + (LoadoutType.Infantry, line) + } else { + (LoadoutType.Vehicle, line - 10) + } + sessionActor ! SessionActor.SendResponse( + FavoritesMessage( + mtype, + session.get.player.GUID, + lineNo, + "", + 0 + ) + ) + case _ => ; + } } def loadLocker(): Future[LockerContainer] = { diff --git a/src/main/scala/net/psforever/actors/session/SessionActor.scala b/src/main/scala/net/psforever/actors/session/SessionActor.scala index af40d8058..679a766b4 100644 --- a/src/main/scala/net/psforever/actors/session/SessionActor.scala +++ b/src/main/scala/net/psforever/actors/session/SessionActor.scala @@ -332,8 +332,8 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con } } // when going from classic -> typed this seems necessary - akka.actor.TypedActor(context.system).poisonPill(avatarActor) - akka.actor.TypedActor(context.system).poisonPill(chatActor) + context.stop(avatarActor) + context.stop(chatActor) } def ValidObject(id: Int): Option[PlanetSideGameObject] = ValidObject(Some(PlanetSideGUID(id))) @@ -3078,7 +3078,7 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con sendResponse(CreateShortcutMessage(guid, 1, 0, true, Shortcut.Medkit)) sendResponse(ChangeShortcutBankMessage(guid, 0)) //Favorites lists - avatarActor ! AvatarActor.RefreshLoadouts() + avatarActor ! AvatarActor.InitialRefreshLoadouts() sendResponse( SetChatFilterMessage(ChatChannel.Platoon, false, ChatChannel.values.toList) @@ -5105,7 +5105,6 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con case msg @ FavoritesRequest(player_guid, loadoutType, action, line, label) => CancelZoningProcessWithDescriptiveReason("cancel_use") - log.info(s"${player.Name} wishes to load a saved favorite loadout") action match { case FavoritesAction.Save => avatarActor ! AvatarActor.SaveLoadout(player, loadoutType, label, line) case FavoritesAction.Delete => avatarActor ! AvatarActor.DeleteLoadout(player, loadoutType, line) diff --git a/src/main/scala/net/psforever/persistence/Vehicleloadout.scala b/src/main/scala/net/psforever/persistence/Vehicleloadout.scala new file mode 100644 index 000000000..905666f99 --- /dev/null +++ b/src/main/scala/net/psforever/persistence/Vehicleloadout.scala @@ -0,0 +1,4 @@ +// Copyright (c) 2021 PSForever +package net.psforever.persistence + +case class Vehicleloadout(id: Int, avatarId: Int, loadoutNumber: Int, name: String, vehicle: Int, items: String) diff --git a/src/main/scala/net/psforever/util/DefinitionUtil.scala b/src/main/scala/net/psforever/util/DefinitionUtil.scala index 7d1cade70..ea6f9ac5d 100644 --- a/src/main/scala/net/psforever/util/DefinitionUtil.scala +++ b/src/main/scala/net/psforever/util/DefinitionUtil.scala @@ -218,7 +218,50 @@ object DefinitionUtil { case 39 => advanced_ace case 148 => boomer case 149 => boomer_trigger - case _ => frag_grenade + //vehicles + case 67 => apc_tr + case 66 => apc_nc + case 68 => apc_vs + case 46 => ams + case 60 => ant + //case 83 => aphelion_flight + //case 84 => aphelion_gunner + case 118 => aurora + case 135 => battlewagon + //case 199 => colossus_flight + //case 200 => colossus_gunner + case 258 => droppod + case 259 => dropship + case 294 => flail + case 335 => fury + case 338 => galaxy_gunship + case 432 => liberator + case 441 => lightgunship + case 446 => lightning + case 459 => lodestar + case 470 => magrider + case 572 => mosquito + case 532 => mediumtransport + case 608 => orbital_shuttle + //case 642 => peregrine_flight + //case 643 => peregrine_gunner + case 671 => phantasm + case 697 => prowler + case 707 => quadassault + case 710 => quadstealth + case 741 => router + case 784 => skyguard + case 847 => switchblade + case 862 => threemanheavybuggy + case 865 => thunderer + case 896 => two_man_assault_buggy + case 898 => twomanheavybuggy + case 900 => twomanhoverbuggy + case 923 => vanguard + case 986 => vulture + case 997 => wasp + //default + case _ => throw new IllegalArgumentException(s"you can not build $id") } }