diff --git a/common/src/main/scala/net/psforever/objects/Avatar.scala b/common/src/main/scala/net/psforever/objects/Avatar.scala index 1307ee58d..4b6891874 100644 --- a/common/src/main/scala/net/psforever/objects/Avatar.scala +++ b/common/src/main/scala/net/psforever/objects/Avatar.scala @@ -1,17 +1,17 @@ // Copyright (c) 2017 PSForever package net.psforever.objects -import net.psforever.objects.avatar.DeployableToolbox +import net.psforever.objects.avatar.{DeployableToolbox, LoadoutManager} import net.psforever.objects.definition.{AvatarDefinition, ImplantDefinition} import net.psforever.objects.equipment.{EquipmentSize, EquipmentSlot} -import net.psforever.objects.loadouts.Loadout import net.psforever.packet.game.objectcreate.Cosmetics import net.psforever.types._ import scala.annotation.tailrec import scala.collection.mutable -class Avatar(val name : String, val faction : PlanetSideEmpire.Value, val sex : CharacterGender.Value, val head : Int, val voice : CharacterVoice.Value) { +class Avatar(private val char_id : Long, val name : String, val faction : PlanetSideEmpire.Value, val sex : CharacterGender.Value, val head : Int, val voice : CharacterVoice.Value) { + /** char_id, Character ID; a unique identifier corresponding to a database table row index */ /** Battle Experience Points */ private var bep : Long = 0 /** Command Experience Points */ @@ -29,11 +29,15 @@ class Avatar(val name : String, val faction : PlanetSideEmpire.Value, val sex : * @see `DetailedCharacterData.implants` */ private val implants : Array[ImplantSlot] = Array.fill[ImplantSlot](3)(new ImplantSlot) - /** Loadouts
- * 0-9 are Infantry loadouts + /** Equipment Loadouts
+ * 0-9 are Infantry loadouts
* 10-14 are Vehicle loadouts */ - private val loadouts : Array[Option[Loadout]] = Array.fill[Option[Loadout]](15)(None) + private val equipmentLoadouts : LoadoutManager = new LoadoutManager(15) + /** + * Squad Loadouts + */ + private val squadLoadouts : LoadoutManager = new LoadoutManager(10) /** Locker */ private val locker : LockerContainer = new LockerContainer() { override def toString : String = { @@ -42,6 +46,14 @@ class Avatar(val name : String, val faction : PlanetSideEmpire.Value, val sex : } private val deployables : DeployableToolbox = new DeployableToolbox + /** + * Looking For Squad:
+ * Indicates both a player state and the text on the marquee under the player nameplate. + * Should only be valid when the player is not in a squad. + */ + private var lfs : Boolean = false + + def CharId : Long = char_id def BEP : Long = bep @@ -164,25 +176,9 @@ class Avatar(val name : String, val faction : PlanetSideEmpire.Value, val sex : }) } - def SaveLoadout(owner : Player, label : String, line : Int) : Unit = { - if(line > -1 && line < 10) { - loadouts(line) = Some(Loadout.Create(owner, label)) - } - } + def EquipmentLoadouts : LoadoutManager = equipmentLoadouts - def SaveLoadout(owner : Vehicle, label : String, line : Int) : Unit = { - if(line > 9 && line < loadouts.length) { - loadouts(line) = Some(Loadout.Create(owner, label)) - } - } - - def LoadLoadout(line : Int) : Option[Loadout] = loadouts.lift(line).flatten - - def DeleteLoadout(line : Int) : Unit = { - loadouts(line) = None - } - - def Loadouts : Seq[(Int, Loadout)] = loadouts.zipWithIndex.collect { case(Some(loadout), index) => (index, loadout) } toSeq + def SquadLoadouts : LoadoutManager = squadLoadouts def Locker : LockerContainer = locker @@ -194,6 +190,13 @@ class Avatar(val name : String, val faction : PlanetSideEmpire.Value, val sex : def Deployables : DeployableToolbox = deployables + def LFS : Boolean = lfs + + def LFS_=(looking : Boolean) : Boolean = { + lfs = looking + LFS + } + def Definition : AvatarDefinition = GlobalDefinitions.avatar /* @@ -228,7 +231,7 @@ class Avatar(val name : String, val faction : PlanetSideEmpire.Value, val sex : object Avatar { def apply(name : String, faction : PlanetSideEmpire.Value, sex : CharacterGender.Value, head : Int, voice : CharacterVoice.Value) : Avatar = { - new Avatar(name, faction, sex, head, voice) + new Avatar(0L, name, faction, sex, head, voice) } def toString(avatar : Avatar) : String = s"${avatar.faction} ${avatar.name}" diff --git a/common/src/main/scala/net/psforever/objects/Player.scala b/common/src/main/scala/net/psforever/objects/Player.scala index a3b5e7f2b..250c81b2e 100644 --- a/common/src/main/scala/net/psforever/objects/Player.scala +++ b/common/src/main/scala/net/psforever/objects/Player.scala @@ -1,10 +1,10 @@ // Copyright (c) 2017 PSForever package net.psforever.objects +import net.psforever.objects.avatar.LoadoutManager import net.psforever.objects.definition.{AvatarDefinition, ExoSuitDefinition, SpecialExoSuitDefinition} import net.psforever.objects.equipment.{Equipment, EquipmentSize, EquipmentSlot} import net.psforever.objects.inventory.{Container, GridInventory, InventoryItem} -import net.psforever.objects.loadouts.Loadout import net.psforever.objects.serverobject.affinity.FactionAffinity import net.psforever.objects.vital.resistance.ResistanceProfile import net.psforever.objects.vital.{DamageResistanceModel, Vitality} @@ -58,6 +58,8 @@ class Player(private val core : Avatar) extends PlanetSideGameObject Player.SuitSetup(this, exosuit) + def CharId : Long = core.CharId + def Name : String = core.name def Faction : PlanetSideEmpire.Value = core.faction @@ -68,6 +70,8 @@ class Player(private val core : Avatar) extends PlanetSideGameObject def Voice : CharacterVoice.Value = core.voice + def LFS : Boolean = core.LFS + def isAlive : Boolean = alive def isBackpack : Boolean = backpack @@ -294,7 +298,9 @@ class Player(private val core : Avatar) extends PlanetSideGameObject def RadiationShielding = exosuit.RadiationShielding - def LoadLoadout(line : Int) : Option[Loadout] = core.LoadLoadout(line) + def EquipmentLoadouts : LoadoutManager = core.EquipmentLoadouts + + def SquadLoadouts : LoadoutManager = core.SquadLoadouts def BEP : Long = core.BEP diff --git a/common/src/main/scala/net/psforever/objects/avatar/LoadoutManager.scala b/common/src/main/scala/net/psforever/objects/avatar/LoadoutManager.scala new file mode 100644 index 000000000..44a7ab771 --- /dev/null +++ b/common/src/main/scala/net/psforever/objects/avatar/LoadoutManager.scala @@ -0,0 +1,29 @@ +// Copyright (c) 2019 PSForever +package net.psforever.objects.avatar + +import net.psforever.objects.loadouts.Loadout +import net.psforever.types.LoadoutType + +import scala.util.Success + +class LoadoutManager(size : Int) { + private val entries : Array[Option[Loadout]] = Array.fill[Option[Loadout]](size)(None) + + def SaveLoadout(owner : Any, label : String, line : Int) : Unit = { + Loadout.Create(owner, label) match { + case Success(loadout) if entries.length > line => + entries(line) = Some(loadout) + case _ => ; + } + } + + def LoadLoadout(line : Int) : Option[Loadout] = entries.lift(line).flatten + + def DeleteLoadout(line : Int) : Unit = { + if(entries.length > line) { + entries(line) = None + } + } + + def Loadouts : Seq[(Int, Loadout)] = entries.zipWithIndex.collect { case(Some(loadout), index) => (index, loadout) } toSeq +} diff --git a/common/src/main/scala/net/psforever/objects/definition/converter/AvatarConverter.scala b/common/src/main/scala/net/psforever/objects/definition/converter/AvatarConverter.scala index 163d698cd..4b7a217f2 100644 --- a/common/src/main/scala/net/psforever/objects/definition/converter/AvatarConverter.scala +++ b/common/src/main/scala/net/psforever/objects/definition/converter/AvatarConverter.scala @@ -81,7 +81,7 @@ object AvatarConverter { ), obj.ExoSuit, 0, - 0L, + obj.CharId, 0, 0, 0, @@ -98,7 +98,7 @@ object AvatarConverter { false, facingPitch = obj.Orientation.y, facingYawUpper = obj.FacingYawUpper, - lfs = true, + obj.LFS, GrenadeState.None, obj.Cloaked, false, diff --git a/common/src/main/scala/net/psforever/objects/loadouts/EquipmentLoadout.scala b/common/src/main/scala/net/psforever/objects/loadouts/EquipmentLoadout.scala new file mode 100644 index 000000000..53925f90c --- /dev/null +++ b/common/src/main/scala/net/psforever/objects/loadouts/EquipmentLoadout.scala @@ -0,0 +1,22 @@ +// Copyright (c) 2017 PSForever +package net.psforever.objects.loadouts + +/** + * The base of all specific kinds of blueprint containers. + * This previous state can be restored on any appropriate template from which the loadout was copied + * by reconstructing any items (if warranted and permitted) or restoring any appropriate fields. + * The three fields are the name assigned to the loadout, + * the visible items that are created (which obey different rules depending on the source), + * and the concealed items that are created and added to the source's `Inventory`.
+ * For example, the `visible_slots` on a `Player`-borne loadout will transform into the form `Array[EquipmentSlot]`; + * `Vehicle`-originating loadouts transform into the form `Map[Int, Equipment]`. + *
+ * The lists of user-specific loadouts are initialized with `FavoritesMessage` packets. + * Specific entries are loaded or removed using `FavoritesRequest` packets. + * @param label the name by which this inventory will be known when displayed in a Favorites list + * @param visible_slots simplified representation of the `Equipment` that can see "seen" on the target + * @param inventory simplified representation of the `Equipment` in the target's inventory or trunk + */ +abstract class EquipmentLoadout(label : String, + visible_slots : List[Loadout.SimplifiedEntry], + inventory : List[Loadout.SimplifiedEntry]) extends Loadout(label) diff --git a/common/src/main/scala/net/psforever/objects/loadouts/InfantryLoadout.scala b/common/src/main/scala/net/psforever/objects/loadouts/InfantryLoadout.scala index 0f9986e29..18c2f57fe 100644 --- a/common/src/main/scala/net/psforever/objects/loadouts/InfantryLoadout.scala +++ b/common/src/main/scala/net/psforever/objects/loadouts/InfantryLoadout.scala @@ -28,7 +28,7 @@ final case class InfantryLoadout(label : String, visible_slots : List[Loadout.SimplifiedEntry], inventory : List[Loadout.SimplifiedEntry], exosuit : ExoSuitType.Value, - subtype : Int) extends Loadout(label, visible_slots, inventory) + subtype : Int) extends EquipmentLoadout(label, visible_slots, inventory) object InfantryLoadout { import net.psforever.objects.Player diff --git a/common/src/main/scala/net/psforever/objects/loadouts/Loadout.scala b/common/src/main/scala/net/psforever/objects/loadouts/Loadout.scala index 12e0cf410..64668c4e7 100644 --- a/common/src/main/scala/net/psforever/objects/loadouts/Loadout.scala +++ b/common/src/main/scala/net/psforever/objects/loadouts/Loadout.scala @@ -5,30 +5,29 @@ import net.psforever.objects._ import net.psforever.objects.definition._ import net.psforever.objects.equipment.{Equipment, EquipmentSlot} import net.psforever.objects.inventory.InventoryItem +import net.psforever.objects.teamwork.Squad import scala.annotation.tailrec +import scala.util.{Failure, Success, Try} /** * The base of all specific kinds of blueprint containers. * This previous state can be restored on any appropriate template from which the loadout was copied - * by reconstructing the items (if permitted). - * The three fields are the name assigned to the loadout, - * the visible items that are created (which obey different rules depending on the source), - * and the concealed items that are created and added to the source's `Inventory`.
- * For example, the `visible_slots` on a `Player`-borne loadout will transform into the form `Array[EquipmentSlot]`; - * `Vehicle`-originating loadouts transform into the form `Map[Int, Equipment]`. - *
- * The lists of user-specific loadouts are initialized with `FavoritesMessage` packets. - * Specific entries are loaded or removed using `FavoritesRequest` packets. + * by reconstructing any items (if warranted and permitted) or restoring any appropriate fields. * @param label the name by which this inventory will be known when displayed in a Favorites list - * @param visible_slots simplified representation of the `Equipment` that can see "seen" on the target - * @param inventory simplified representation of the `Equipment` in the target's inventory or trunk */ -abstract class Loadout(label : String, - visible_slots : List[Loadout.SimplifiedEntry], - inventory : List[Loadout.SimplifiedEntry]) +abstract class Loadout(label : String) object Loadout { + def Create(owner : Any, label : String) : Try[Loadout] = { + owner match { + case p : Player => Success(Create(p, label)) + case v : Vehicle => Success(Create(v, label)) + case s : Squad => Success(Create(s, s.Task)) + case _ => Failure(new MatchError(s"can not create a loadout based on the (current status of) $owner")) + } + } + /** * Produce the blueprint on a player. * @param player the player @@ -54,12 +53,31 @@ object Loadout { def Create(vehicle : Vehicle, label : String) : Loadout = { VehicleLoadout( label, - packageSimplifications(vehicle.Weapons.map({ case ((index, weapon)) => InventoryItem(weapon.Equipment.get, index) }).toList), + packageSimplifications(vehicle.Weapons.map({ case (index, weapon) => InventoryItem(weapon.Equipment.get, index) }).toList), packageSimplifications(vehicle.Trunk.Items), vehicle.Definition ) } + /** + * na + */ + def Create(squad : Squad, label : String) : Loadout = { + SquadLoadout( + label, + if(squad.CustomZoneId) { Some(squad.ZoneId) } else { None }, + squad.Membership + .zipWithIndex + .filter { case (_, index) => + squad.Availability(index) + } + .map {case (member, index) => + SquadPositionLoadout(index, member.Role, member.Orders, member.Requirements) + } + .toList + ) + } + /** * A basic `Trait` connecting all of the `Equipment` blueprints. */ diff --git a/common/src/main/scala/net/psforever/objects/loadouts/SquadLoadout.scala b/common/src/main/scala/net/psforever/objects/loadouts/SquadLoadout.scala new file mode 100644 index 000000000..029c58b7f --- /dev/null +++ b/common/src/main/scala/net/psforever/objects/loadouts/SquadLoadout.scala @@ -0,0 +1,10 @@ +// Copyright (c) 2019 PSForever +package net.psforever.objects.loadouts + +import net.psforever.types.CertificationType + +final case class SquadPositionLoadout(index : Int, role : String, orders : String, requirements : Set[CertificationType.Value]) + +final case class SquadLoadout(task : String, + zone_id : Option[Int], + members : List[SquadPositionLoadout]) extends Loadout(task) diff --git a/common/src/main/scala/net/psforever/objects/loadouts/VehicleLoadout.scala b/common/src/main/scala/net/psforever/objects/loadouts/VehicleLoadout.scala index 5f7e0b63a..c4199e0ce 100644 --- a/common/src/main/scala/net/psforever/objects/loadouts/VehicleLoadout.scala +++ b/common/src/main/scala/net/psforever/objects/loadouts/VehicleLoadout.scala @@ -24,4 +24,4 @@ import net.psforever.objects.definition._ final case class VehicleLoadout(label : String, visible_slots : List[Loadout.SimplifiedEntry], inventory : List[Loadout.SimplifiedEntry], - vehicle_definition : VehicleDefinition) extends Loadout(label, visible_slots, inventory) + vehicle_definition : VehicleDefinition) extends EquipmentLoadout(label, visible_slots, inventory) diff --git a/common/src/main/scala/net/psforever/objects/serverobject/terminals/OrderTerminalDefinition.scala b/common/src/main/scala/net/psforever/objects/serverobject/terminals/OrderTerminalDefinition.scala index 4784d8de3..9c4e34d7c 100644 --- a/common/src/main/scala/net/psforever/objects/serverobject/terminals/OrderTerminalDefinition.scala +++ b/common/src/main/scala/net/psforever/objects/serverobject/terminals/OrderTerminalDefinition.scala @@ -258,7 +258,7 @@ object OrderTerminalDefinition { //TODO block equipment by blocking ammunition type final case class InfantryLoadoutPage() extends LoadoutTab { override def Buy(player : Player, msg : ItemTransactionMessage) : Terminal.Exchange = { - player.LoadLoadout(msg.unk1) match { + player.EquipmentLoadouts.LoadLoadout(msg.unk1) match { case Some(loadout : InfantryLoadout) if !Exclude.contains(loadout.exosuit) && !Exclude.contains((loadout.exosuit, loadout.subtype)) => val holsters = loadout.visible_slots .map(entry => { InventoryItem(BuildSimplifiedPattern(entry.item), entry.index) }) @@ -287,7 +287,7 @@ object OrderTerminalDefinition { */ final case class VehicleLoadoutPage() extends LoadoutTab { override def Buy(player : Player, msg : ItemTransactionMessage) : Terminal.Exchange = { - player.LoadLoadout(msg.unk1 + 10) match { + player.EquipmentLoadouts.LoadLoadout(msg.unk1 + 10) match { case Some(loadout : VehicleLoadout) if !Exclude.contains(loadout.vehicle_definition) => val weapons = loadout.visible_slots .map(entry => { InventoryItem(BuildSimplifiedPattern(entry.item), entry.index) }) diff --git a/common/src/main/scala/net/psforever/objects/teamwork/Member.scala b/common/src/main/scala/net/psforever/objects/teamwork/Member.scala new file mode 100644 index 000000000..a2406a515 --- /dev/null +++ b/common/src/main/scala/net/psforever/objects/teamwork/Member.scala @@ -0,0 +1,89 @@ +// Copyright (c) 2019 PSForever +package net.psforever.objects.teamwork + +import net.psforever.types.{CertificationType, Vector3} + +class Member { + //about the position to be filled + private var role : String = "" + private var orders : String = "" + private var requirements : Set[CertificationType.Value] = Set() + //about the individual filling the position + private var name : String = "" + private var charId : Long = 0L + private var health : Int = 0 + private var armor : Int = 0 + private var zoneId : Int = 0 + private var position : Vector3 = Vector3.Zero + + def Role : String = role + + def Role_=(title : String) : String = { + role = title + Role + } + + def Orders : String = orders + + def Orders_=(text : String) : String = { + orders = text + Orders + } + + def Requirements : Set[CertificationType.Value] = requirements + + def Requirements_=(req : Set[CertificationType.Value]) : Set[CertificationType.Value] = { + requirements = req + Requirements + } + + def Name : String = name + + def Name_=(moniker : String) : String = { + name = moniker + Name + } + + def CharId : Long = charId + + def CharId_=(id : Long) : Long = { + charId = id + CharId + } + + def Health : Int = health + + def Health_=(red : Int) : Int = { + health = red + Health + } + + def Armor : Int = armor + + def Armor_=(blue : Int) : Int = { + armor = blue + Armor + } + + def ZoneId : Int = zoneId + + def ZoneId_=(id : Int) : Int = { + zoneId = id + ZoneId + } + + def Position : Vector3 = position + + def Position_=(pos : Vector3) : Vector3 = { + position = pos + Position + } + + def isAvailable : Boolean = { + charId == 0 + } + + def isAvailable(certs : Set[CertificationType.Value]) : Boolean = { + isAvailable && certs.intersect(requirements) == requirements + } +} diff --git a/common/src/main/scala/net/psforever/objects/teamwork/Squad.scala b/common/src/main/scala/net/psforever/objects/teamwork/Squad.scala new file mode 100644 index 000000000..0ecd2e20a --- /dev/null +++ b/common/src/main/scala/net/psforever/objects/teamwork/Squad.scala @@ -0,0 +1,85 @@ +// Copyright (c) 2019 PSForever +package net.psforever.objects.teamwork + +import net.psforever.objects.entity.IdentifiableEntity +import net.psforever.packet.game.PlanetSideGUID +import net.psforever.types.{CertificationType, PlanetSideEmpire} + +class Squad(squadId : PlanetSideGUID, alignment : PlanetSideEmpire.Value) extends IdentifiableEntity { + super.GUID_=(squadId) + private val faction : PlanetSideEmpire.Value = alignment //does not change + private var zoneId : Option[Int] = None + private var task : String = "" + private val membership : Array[Member] = Array.fill[Member](10)(new Member) + private val availability : Array[Boolean] = Array.fill[Boolean](10)(elem = true) + + override def GUID_=(d : PlanetSideGUID) : PlanetSideGUID = GUID + + def Faction : PlanetSideEmpire.Value = faction + + def CustomZoneId : Boolean = zoneId.isDefined + + def ZoneId : Int = zoneId.getOrElse(membership(0).ZoneId) + + def ZoneId_=(id : Int) : Int = { + ZoneId_=(Some(id)) + } + + def ZoneId_=(id : Option[Int]) : Int = { + zoneId = id + ZoneId + } + + def Task : String = task + + def Task_=(assignment : String) : String = { + task = assignment + Task + } + + def Membership : Array[Member] = membership + + def Availability : Array[Boolean] = availability + + def Leader : Member = { + membership(0) match { + case member if !member.Name.equals("") => + member + case _ => + throw new Exception("can not find squad leader!") + } + } + + def Size : Int = membership.count(member => member.CharId != 0) + + def Capacity : Int = availability.count(open => open) + + def isAvailable(role : Int) : Boolean = { + availability.lift(role) match { + case Some(true) => + membership(role).isAvailable + case _ => + false + } + } + + def isAvailable(role : Int, certs : Set[CertificationType.Value]) : Boolean = { + availability.lift(role) match { + case Some(true) => + membership(role).isAvailable(certs) + case _ => + false + } + } +} + +object Squad { + final val Blank = new Squad(PlanetSideGUID(0), PlanetSideEmpire.NEUTRAL) { + override def ZoneId : Int = 0 + override def ZoneId_=(id : Int) : Int = 0 + override def ZoneId_=(id : Option[Int]) : Int = 0 + override def Task_=(assignment : String) : String = "" + override def Membership : Array[Member] = Array.empty[Member] + override def Availability : Array[Boolean] = Array.fill[Boolean](10)(false) + } +} diff --git a/common/src/main/scala/net/psforever/objects/teamwork/SquadFeatures.scala b/common/src/main/scala/net/psforever/objects/teamwork/SquadFeatures.scala new file mode 100644 index 000000000..2e1a5f056 --- /dev/null +++ b/common/src/main/scala/net/psforever/objects/teamwork/SquadFeatures.scala @@ -0,0 +1,142 @@ +// Copyright (c) 2019 PSForever +package net.psforever.objects.teamwork + +import akka.actor.{Actor, ActorContext, ActorRef, Props} +import net.psforever.types.SquadWaypoints +import services.teamwork.SquadService.WaypointData +import services.teamwork.SquadSwitchboard + +class SquadFeatures(val Squad : Squad) { + /** + * `initialAssociation` per squad is similar to "Does this squad want to recruit members?" + * The squad does not have to be flagged. + * Dispatches an `AssociateWithSquad` `SquadDefinitionActionMessage` packet to the squad leader and ??? + * and then a `SquadDetailDefinitionUpdateMessage` that includes at least the squad owner name and char id. + * Dispatched only once when a squad is first listed + * or when the squad leader searches for recruits by proximity or for certain roles or by invite + * or when a spontaneous squad forms, + * whatever happens first. + * Additionally, the packets are also sent when the check is made when the continent is changed (or set). + */ + private var initialAssociation : Boolean = true + /** + * na + */ + private var switchboard : ActorRef = ActorRef.noSender + /** + * Waypoint data. + * The first four slots are used for squad waypoints. + * The fifth slot is used for the squad leader experience waypoint.
+ *
+ * All of the waypoints constantly exist as long as the squad to which they are attached exists. + * They are merely "activated" and "deactivated." + * When "activated," the waypoint knows on which continent to appear and where on the map and in the game world to be positioned. + * Waypoints manifest in the game world as a far-off beam of light that extends into the sky + * and whose ground contact utilizes a downwards pulsating arrow. + * On the continental map and deployment map, they appear as a diamond, with a differentiating number where applicable. + * The squad leader experience rally, for example, does not have a number like the preceding four waypoints. + * @see `Start` + */ + private var waypoints : Array[WaypointData] = Array[WaypointData]() + /** + * The particular position being recruited right at the moment. + * When `None`. no highlighted searches have been indicated. + * When a positive integer or 0, indicates distributed `LookingForSquadRoleInvite` messages as recorded by `proxyInvites`. + * Only one position may bne actively recruited at a time in this case. + * When -1, indicates distributed `ProximityIvite` messages as recorded by `proxyInvites`. + * Previous efforts may or may not be forgotten if there is a switch between the two modes. + */ + private var searchForRole : Option[Int] = None + /** + * Handle persistent data related to `ProximityInvite` and `LookingForSquadRoleInvite` messages + */ + private var proxyInvites : List[Long] = Nil + /** + * These useres rejected invitation to this squad. + * For the purposes of wide-searches for membership + * such as Looking For Squad checks and proximity invitation, + * the unique character identifier numbers in this list are skipped. + * Direct invitation requests from the non sqad member should remain functional. + */ + private var refusedPlayers : List[Long] = Nil + private var autoApproveInvitationRequests : Boolean = true + private var locationFollowsSquadLead : Boolean = true + + private var listed : Boolean = false + + private lazy val channel : String = s"${Squad.Faction}-Squad${Squad.GUID.guid}" + + def Start(implicit context : ActorContext) : SquadFeatures = { + switchboard = context.actorOf(Props[SquadSwitchboard], s"squad${Squad.GUID.guid}") + waypoints = Array.fill[WaypointData](SquadWaypoints.values.size)(new WaypointData()) + this + } + + def Stop : SquadFeatures = { + switchboard ! akka.actor.PoisonPill + switchboard = Actor.noSender + waypoints = Array.empty + this + } + + def InitialAssociation : Boolean = initialAssociation + + def InitialAssociation_=(assoc : Boolean) : Boolean = { + initialAssociation = assoc + InitialAssociation + } + + def Switchboard : ActorRef = switchboard + + def Waypoints : Array[WaypointData] = waypoints + + def SearchForRole : Option[Int] = searchForRole + + def SearchForRole_=(role : Int) : Option[Int] = SearchForRole_=(Some(role)) + + def SearchForRole_=(role : Option[Int]) : Option[Int] = { + searchForRole = role + SearchForRole + } + + def ProxyInvites : List[Long] = proxyInvites + + def ProxyInvites_=(list : List[Long]) : List[Long] = { + proxyInvites = list + ProxyInvites + } + + def Refuse : List[Long] = refusedPlayers + + def Refuse_=(charId : Long) : List[Long] = { + Refuse_=(List(charId)) + } + + def Refuse_=(list : List[Long]) : List[Long] = { + refusedPlayers = list ++ refusedPlayers + Refuse + } + + def LocationFollowsSquadLead : Boolean = locationFollowsSquadLead + + def LocationFollowsSquadLead_=(follow : Boolean) : Boolean = { + locationFollowsSquadLead = follow + LocationFollowsSquadLead + } + + def AutoApproveInvitationRequests : Boolean = autoApproveInvitationRequests + + def AutoApproveInvitationRequests_=(autoApprove : Boolean) : Boolean = { + autoApproveInvitationRequests = autoApprove + AutoApproveInvitationRequests + } + + def Listed : Boolean = listed + + def Listed_=(announce : Boolean) : Boolean = { + listed = announce + Listed + } + + def ToChannel : String = channel +} diff --git a/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala b/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala index b26a9834c..2d10e1529 100644 --- a/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala +++ b/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala @@ -446,18 +446,18 @@ object GamePacketOpcode extends Enumeration { case 0x6b => game.TriggerSoundMessage.decode case 0x6c => game.LootItemMessage.decode case 0x6d => game.VehicleSubStateMessage.decode - case 0x6e => noDecoder(SquadMembershipRequest) - case 0x6f => noDecoder(SquadMembershipResponse) + case 0x6e => game.SquadMembershipRequest.decode + case 0x6f => game.SquadMembershipResponse.decode // OPCODES 0x70-7f - case 0x70 => noDecoder(SquadMemberEvent) + case 0x70 => game.SquadMemberEvent.decode case 0x71 => noDecoder(PlatoonEvent) case 0x72 => game.FriendsRequest.decode case 0x73 => game.FriendsResponse.decode case 0x74 => game.TriggerEnvironmentalDamageMessage.decode case 0x75 => game.TrainingZoneMessage.decode case 0x76 => game.DeployableObjectsInfoMessage.decode - case 0x77 => noDecoder(SquadState) + case 0x77 => game.SquadState.decode // 0x78 case 0x78 => game.OxygenStateMessage.decode case 0x79 => noDecoder(TradeMessage) @@ -472,7 +472,7 @@ object GamePacketOpcode extends Enumeration { case 0x80 => noDecoder(GenericObjectAction2Message) case 0x81 => game.DestroyDisplayMessage.decode case 0x82 => noDecoder(TriggerBotAction) - case 0x83 => noDecoder(SquadWaypointRequest) + case 0x83 => game.SquadWaypointRequest.decode case 0x84 => game.SquadWaypointEvent.decode case 0x85 => noDecoder(OffshoreVehicleMessage) case 0x86 => game.ObjectDeployedMessage.decode @@ -592,11 +592,11 @@ object GamePacketOpcode extends Enumeration { case 0xe6 => game.ReplicationStreamMessage.decode case 0xe7 => game.SquadDefinitionActionMessage.decode // 0xe8 - case 0xe8 => noDecoder(SquadDetailDefinitionUpdateMessage) + case 0xe8 => game.SquadDetailDefinitionUpdateMessage.decode case 0xe9 => noDecoder(TacticsMessage) case 0xea => noDecoder(RabbitUpdateMessage) - case 0xeb => noDecoder(SquadInvitationRequestMessage) - case 0xec => noDecoder(CharacterKnowledgeMessage) + case 0xeb => game.SquadInvitationRequestMessage.decode + case 0xec => game.CharacterKnowledgeMessage.decode case 0xed => noDecoder(GameScoreUpdateMessage) case 0xee => noDecoder(UnknownMessage238) case 0xef => noDecoder(OrderTerminalBugMessage) diff --git a/common/src/main/scala/net/psforever/packet/game/CharacterKnowledgeMessage.scala b/common/src/main/scala/net/psforever/packet/game/CharacterKnowledgeMessage.scala new file mode 100644 index 000000000..3b9710a3b --- /dev/null +++ b/common/src/main/scala/net/psforever/packet/game/CharacterKnowledgeMessage.scala @@ -0,0 +1,57 @@ +// Copyright (c) 2019 PSForever +package net.psforever.packet.game + +import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket} +import net.psforever.types.CertificationType +import scodec.Codec +import scodec.codecs._ +import shapeless.{::, HNil} + +final case class CharacterKnowledgeInfo(name : String, + permissions : Set[CertificationType.Value], + unk1 : Int, + unk2 : Int, + unk3 : PlanetSideGUID) + +final case class CharacterKnowledgeMessage(char_id : Long, + info : Option[CharacterKnowledgeInfo]) + extends PlanetSideGamePacket { + type Packet = CharacterKnowledgeMessage + def opcode = GamePacketOpcode.CharacterKnowledgeMessage + def encode = CharacterKnowledgeMessage.encode(this) +} + +object CharacterKnowledgeMessage extends Marshallable[CharacterKnowledgeMessage] { + def apply(char_id : Long) : CharacterKnowledgeMessage = + CharacterKnowledgeMessage(char_id, None) + + def apply(char_id : Long, info : CharacterKnowledgeInfo) : CharacterKnowledgeMessage = + CharacterKnowledgeMessage(char_id, Some(info)) + + private val inverter : Codec[Boolean] = bool.xmap[Boolean] ( + state => !state, + state => !state + ) + + private val info_codec : Codec[CharacterKnowledgeInfo] = ( + ("name" | PacketHelpers.encodedWideStringAligned(adjustment = 7)) :: + ("permissions" | ulongL(bits = 46)) :: + ("unk1" | uint(bits = 6)) :: + ("unk2" | uint(bits = 3)) :: + ("unk3" | PlanetSideGUID.codec) + ).xmap[CharacterKnowledgeInfo] ( + { + case name :: permissions :: u1 :: u2 :: u3 :: HNil => + CharacterKnowledgeInfo(name, CertificationType.fromEncodedLong(permissions), u1, u2, u3) + }, + { + case CharacterKnowledgeInfo(name, permissions, u1, u2, u3) => + name :: CertificationType.toEncodedLong(permissions) :: u1 :: u2 :: u3 :: HNil + } + ) + + implicit val codec : Codec[CharacterKnowledgeMessage] = ( + ("char_id" | uint32L) :: + ("info" | optional(inverter, info_codec)) + ).as[CharacterKnowledgeMessage] +} diff --git a/common/src/main/scala/net/psforever/packet/game/PlanetsideAttributeMessage.scala b/common/src/main/scala/net/psforever/packet/game/PlanetsideAttributeMessage.scala index eec5c3124..4c33eab11 100644 --- a/common/src/main/scala/net/psforever/packet/game/PlanetsideAttributeMessage.scala +++ b/common/src/main/scala/net/psforever/packet/game/PlanetsideAttributeMessage.scala @@ -118,8 +118,12 @@ import scodec.codecs._ * `27 - PA_JAMMED - plays jammed buzzing sound`
* `28 - PA_IMPLANT_ACTIVE - Plays implant sounds. Valid values seem to be up to 20.`
* `29 - PA_VAPORIZED - Visible ?! That's not the cloaked effect, Maybe for spectator mode ?. Value is 0 to visible, 1 to invisible.`
- * `31 - Info under avatar name : 0 = LFS, 1 = Looking For Squad Members`
- * `32 - Info under avatar name : 0 = Looking For Squad Members, 1 = LFS`
+ * `31 - Looking for Squad info (marquee and ui):
+ * ` - 0 is LFS`
+ * ` - 1 is LFSM (Looking for Squad Members)`
+ * ` - n is the supplemental squad identifier number; same as "LFS;" for the leader, sets "LFSM" after the first manual flagging`
+ * `32 - Maintain the squad role index, when a member of a squad;
+ * - OLD: "Info under avatar name : 0 = Looking For Squad Members, 1 = LFS`"
* `35 - BR. Value is the BR`
* `36 - CR. Value is the CR`
* `43 - Info on avatar name : 0 = Nothing, 1 = "(LD)" message`
diff --git a/common/src/main/scala/net/psforever/packet/game/ReplicationStreamMessage.scala b/common/src/main/scala/net/psforever/packet/game/ReplicationStreamMessage.scala index 0f4aafc30..c34a057ce 100644 --- a/common/src/main/scala/net/psforever/packet/game/ReplicationStreamMessage.scala +++ b/common/src/main/scala/net/psforever/packet/game/ReplicationStreamMessage.scala @@ -5,90 +5,98 @@ import net.psforever.newcodecs.newcodecs import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket} import scodec.{Attempt, Codec, Err} import scodec.codecs._ -import shapeless.{::, HNil} +import shapeless.HNil //DO NOT IMPORT shapeless.:: HERE; it interferes with required scala.collection.immutable.:: + +import scala.annotation.tailrec /** - * Maintains squad information changes performed by this listing. + * Maintain squad information for a given squad's listing. * Only certain information will be transmitted depending on the purpose of the packet. - * @param leader the name of the squad leader as a wide character string, or `None` if not applicable - * @param task the task the squad is trying to perform as a wide character string, or `None` if not applicable - * @param zone_id the continent on which the squad is acting, or `None` if not applicable - * @param size the current size of the squad, or `None` if not applicable; - * "can" be greater than `capacity`, though with issues - * @param capacity the maximum number of members that the squad can tolerate, or `None` if not applicable; + * @param leader the name of the squad leader, usually the first person in the squad member list; + * `None` if not applicable + * @param task the task the squad is trying to perform as a wide character string; + * `None` if not applicable + * @param zone_id the continent on which the squad is acting; + * `None` if not applicable + * @param size the current size of the squad; + * "can" be greater than `capacity`, though with issues; + * `None` if not applicable + * @param capacity the maximum number of members that the squad can tolerate; * normal count is 10; - * maximum is 15 but naturally can not be assigned that many - * @param squad_guid a GUID associated with the squad, used to recover the squad definition, or `None` if not applicable; - * sometimes it is defined but is still not applicable + * maximum is 15 but naturally can not be assigned that many; + * `None` if not applicable + * @param squad_guid a GUID associated with the squad, used to recover the squad definition; + * sometimes it is defined but is still not applicable; + * `None` if not applicable (rarely applicable) */ final case class SquadInfo(leader : Option[String], task : Option[String], zone_id : Option[PlanetSideZoneID], size : Option[Int], capacity : Option[Int], - squad_guid : Option[PlanetSideGUID] = None) + squad_guid : Option[PlanetSideGUID] = None) { + /** + * Populate the undefined fields of this object with the populated fields of a second object. + * If the field is already defined in this object, the provided object does not contribute new data. + * @param info the `SquadInfo` data to be incorporated into this object's data + * @return a new `SquadInfo` object, combining with two objects' field data + */ + def And(info : SquadInfo) : SquadInfo = { + SquadInfo( + leader.orElse(info.leader), + task.orElse(info.task), + zone_id.orElse(info.zone_id), + size.orElse(info.size), + capacity.orElse(info.capacity), + squad_guid.orElse(info.squad_guid) + ) + } + + //methods intended to combine the fields of itself and another object + def Leader(leader : String) : SquadInfo = + this And SquadInfo(Some(leader), None, None, None, None, None) + def Task(task : String) : SquadInfo = + this And SquadInfo(None, Some(task), None, None, None, None) + def ZoneId(zone : PlanetSideZoneID) : SquadInfo = + this And SquadInfo(None, None, Some(zone), None, None, None) + def ZoneId(zone : Option[PlanetSideZoneID]) : SquadInfo = zone match { + case Some(zoneId) => this And SquadInfo(None, None, zone, None, None, None) + case None => SquadInfo(leader, task, zone, size, capacity, squad_guid) + } + def Size(sz : Int) : SquadInfo = + this And SquadInfo(None, None, None, Some(sz), None, None) + def Capacity(cap : Int) : SquadInfo = + this And SquadInfo(None, None, None, None, Some(cap), None) +} /** - * Define three fields determining the purpose of data in this listing.
- *
- * The third field `unk3` is not always be defined and will be supplanted by the squad (definition) GUID during initialization and a full update.
- *
- * Actions:
- * `unk1 unk2  unk3`
- * `0    true  4 -- `Remove a squad from listing
- * `128  true  0 -- `Update a squad's leader
- * `128  true  1 -- `Update a squad's task or continent
- * `128  true  2 -- `Update a squad's size
- * `129  false 0 -- `Update a squad's leader or size
- * `129  false 1 -- `Update a squad's task and continent
- * `131  false X -- `Add all squads during initialization / update all information pertaining to this squad - * @param unk1 na - * @param unk2 na - * @param unk3 na; - * not always defined - * @param info information pertaining to this squad listing - */ -//TODO when these unk# values are better understood, transform SquadHeader to streamline the actions to be performed -final case class SquadHeader(unk1 : Int, - unk2 : Boolean, - unk3 : Option[Int], - info : Option[SquadInfo] = None) - -/** - * An indexed entry in the listing of squads.
- *
- * Squad listing indices are not an arbitrary order. - * The server communicates changes to the client by referencing a squad's listing index, defined at the time of list initialization. - * Once initialized, each client may organize their squads however they wish, e.g., by leader, by task, etc., without compromising this index. - * During the list initialization process, the entries must always follow numerical order, increasing from `0`. - * During any other operation, the entries may be prefixed with whichever index is necessary to indicate the squad listing in question. - * @param index the index of this listing; - * first entry should be 0, and subsequent valid entries are sequentially incremental; - * last entry is always a placeholder with index 255 - * @param listing the data for this entry, defining both the actions and the pertinent squad information + * An indexed entry in the listing of squads. + * @param index the listing entry index for this squad; + * zero-based; + * 255 is the maximum index and is reserved to indicate the end of the listings for the packet + * @param listing the squad data; + * `None` when the index is 255, or when invoking a "remove" action on any squad at a known index */ final case class SquadListing(index : Int = 255, - listing : Option[SquadHeader] = None) + listing : Option[SquadInfo] = None) /** - * Modify the list of squads available to a given player. - * The squad list updates in real time rather than just whenever a player opens the squad information window.
+ * Display the list of squads available to a given player.
*
- * The four main operations are: initializing the list, updating entries in the list, removing entries from the list, and clearing the list. + * The four main operations are: + * initializing the list, + * updating entries in the list, + * removing entries from the list, + * and clearing the list. * The process of initializing the list and clearing the list actually are performed by similar behavior. * Squads would just not be added after the list clears. * Moreover, removing entries from the list overrides the behavior to update entries in the list. - * The two-three codes per entry (see `SquadHeader`) are important for determining the effect of a specific entry. - * As of the moment, the important details of the behaviors is that they modify how the packet is encoded and padded.
+ * Squad list entries are typically referenced by their line index.
*
- * Referring to information in `SquadListing`, entries are identified by their index in the list. - * This is followed by a coded section that indicates what action the entry will execute on that squad listing. - * After the "coded action" section is the "general information" section where the data for the change is specified. - * In this manner, all the entries will have a knowable length.
- *
- * The total number of entries in a packet is not known until they have all been parsed. + * Though often specified with a global identifier, squads are rarely accessed using that identifier. + * Outside of initialization activities, the specific index of the squad listing is referenced. * During the list initialization process, the entries must be in ascending order of index. - * Otherwise, the specific index of the squad listing is referenced. + * The total number of entries in a packet is not known until they have all been parsed. * The minimum number of entries is "no entries." * The maximum number of entries is supposedly 254. * The last item is always the index 255 and this is interpreted as the end of the stream.
@@ -106,9 +114,10 @@ final case class SquadListing(index : Int = 255, * `5        6         `Clear squad list and initialize new squad list
* `5        6         `Clear squad list (transitions directly into 255-entry)
* `6        X         `Update a squad in the list - * @param behavior a code that suggests the primary purpose of the data in this packet - * @param behavior2 during initialization, this code is read; - * it supplements the normal `behavior` and is typically is an "update" code + * @param behavior a required code that suggests the operations of the data in this packet + * @param behavior2 an optional code that suggests the operations of the data in this packet; + * during initialization, this code is read; + * it typically flags an "update" action * @param entries a `Vector` of the squad listings */ final case class ReplicationStreamMessage(behavior : Int, @@ -121,15 +130,22 @@ final case class ReplicationStreamMessage(behavior : Int, } object SquadInfo { + /** + * An entry where no fields are defined. + */ + final val Blank = SquadInfo() + + def apply() : SquadInfo = SquadInfo(None, None, None, None, None, None) + /** * Alternate constructor for `SquadInfo` that ignores the `Option` requirement for the fields.
*
* This constructor is not actually used at the moment. - * @param leader the name of the squad leader - * @param task the task the squad is trying to perform + * @param leader the name of the squad leader + * @param task the task the squad is trying to perform * @param continent_guid the continent on which the squad is acting - * @param size the current size of the squad - * @param capacity the maximum number of members that the squad can tolerate + * @param size the current size of the squad + * @param capacity the maximum number of members that the squad can tolerate * @return a `SquadInfo` object */ def apply(leader : String, task : String, continent_guid : PlanetSideZoneID, size : Int, capacity : Int) : SquadInfo = { @@ -139,13 +155,13 @@ object SquadInfo { /** * Alternate constructor for `SquadInfo` that ignores the `Option` requirement for the fields.
*
- * This constructor is used by the `initCodec`, `alt_initCodec`, and `allCodec`. - * @param leader the name of the squad leader - * @param task the task the squad is trying to perform + * This constructor is used by the `infoCodec`, `alt_infoCodec`, and `allCodec`. + * @param leader the name of the squad leader + * @param task the task the squad is trying to perform * @param continent_guid the continent on which the squad is acting - * @param size the current size of the squad - * @param capacity the maximum number of members that the squad can tolerate - * @param squad_guid a GUID associated with the squad, used to recover the squad definition + * @param size the current size of the squad + * @param capacity the maximum number of members that the squad can tolerate + * @param squad_guid a GUID associated with the squad, used to recover the squad definition * @return a `SquadInfo` object */ def apply(leader : String, task : String, continent_guid : PlanetSideZoneID, size : Int, capacity : Int, squad_guid : PlanetSideGUID) : SquadInfo = { @@ -153,33 +169,23 @@ object SquadInfo { } /** - * Alternate constructor for `SquadInfo` that ignores the `Option` requirement for the important field.
- *
- * This constructor is used by `leaderCodec`. - * Two of the fields normally are `Option[String]`s. - * Only the `leader` field in this packet is a `String`, giving the method a distinct signature. - * The other field - an `Option[String]` for `task` - can still be set if passed.
- *
- * Recommended use: `SquadInfo(leader, None)` + * Alternate constructor for `SquadInfo` that ignores the `Option` requirement for the important field. * @param leader the name of the squad leader - * @param task the task the squad is trying to perform, if not `None` * @return a `SquadInfo` object */ - def apply(leader : String, task : Option[String]) : SquadInfo = { - SquadInfo(Some(leader), task, None, None, None) + def apply(leader : String) : SquadInfo = { + SquadInfo(Some(leader), None, None, None, None) } /** * Alternate constructor for `SquadInfo` that ignores the `Option` requirement for the important field.
*
- * This constructor is used by `taskOrContinentCodec`. - * Two of the fields normally are `Option[String]`s. * Only the `task` field in this packet is a `String`, giving the method a distinct signature. * The other field - an `Option[String]` for `leader` - can still be set if passed.
*
* Recommended use: `SquadInfo(None, task)` * @param leader the name of the squad leader, if not `None` - * @param task the task the squad is trying to perform + * @param task the task the squad is trying to perform * @return a `SquadInfo` object */ def apply(leader : Option[String], task : String) : SquadInfo = { @@ -187,9 +193,7 @@ object SquadInfo { } /** - * Alternate constructor for `SquadInfo` that ignores the `Option` requirement for the field.
- *
- * This constructor is used by `taskOrContinentCodec`. + * Alternate constructor for `SquadInfo` that ignores the `Option` requirement for the field. * @param continent_guid the continent on which the squad is acting * @return a `SquadInfo` object */ @@ -198,463 +202,589 @@ object SquadInfo { } /** - * Alternate constructor for `SquadInfo` that ignores the `Option` requirement for the important field.
- *
- * This constructor is used by `sizeCodec`. - * Two of the fields normally are `Option[Int]`s. - * Only the `size` field in this packet is an `Int`, giving the method a distinct signature.
- *
- * Recommended use: `SquadInfo(size, None)`
- *
- * Exploration:
- * We do not currently know any `SquadHeader` action codes for adjusting `capacity`. - * @param size the current size of the squad - * @param capacity the maximum number of members that the squad can tolerate, if not `None` + * Alternate constructor for `SquadInfo` that ignores the `Option` requirement for the important field. + * @param size the current size of the squad * @return a `SquadInfo` object */ - def apply(size : Int, capacity : Option[Int]) : SquadInfo = { + def apply(size : Int) : SquadInfo = { SquadInfo(None, None, None, Some(size), None) } /** * Alternate constructor for `SquadInfo` that ignores the `Option` requirement for the important field.
*
- * This constructor is not actually used at the moment. * Two of the fields normally are `Option[Int]`s. - * Only the `capacity` field in this packet is an `Int`, giving the method a distinct signature.
+ * Only the `capacity` field in this packet is an `Int`, giving the method a distinct signature. + * The other field - an `Option[Int]` for `size` - can still be set if passed.
*
- * Recommended use: `SquadInfo(None, capacity)`
- *
- * Exploration:
- * We do not currently know any `SquadHeader` action codes for adjusting `capacity`. - * @param size the current size of the squad + * Recommended use: `SquadInfo(None, capacity)` + * @param size the current size of the squad * @param capacity the maximum number of members that the squad can tolerate, if not `None` * @return a `SquadInfo` object */ def apply(size : Option[Int], capacity : Int) : SquadInfo = { - SquadInfo(None, None, None, None, Some(capacity)) + SquadInfo(None, None, None, size, Some(capacity)) } /** - * Alternate constructor for `SquadInfo` that ignores the `Option` requirement for the fields.
- *
- * This constructor is used by `leaderSizeCodec`. - * @param leader the name of the squad leader - * @param size the current size of the squad - * @return a `SquadInfo` object + * The codes related to the specific application of different `Codec`s when parsing squad information as different fields. + * Hence, "field codes." + * These fields are identified when using `SquadInfo` data in `ReplicationStreamMessage`'s "update" format + * but are considered absent when performing squad list initialization. */ - def apply(leader : String, size : Int) : SquadInfo = { - SquadInfo(Some(leader), None, None, Some(size), None) - } - - /** - * Alternate constructor for `SquadInfo` that ignores the Option requirement for the fields.
- *
- * This constructor is used by `taskAndContinentCodec`. - * @param task the task the squad is trying to perform - * @param continent_guid the continent on which the squad is acting - * @return a `SquadInfo` object - */ - def apply(task : String, continent_guid : PlanetSideZoneID) : SquadInfo = { - SquadInfo(None, Some(task), Some(continent_guid), None, None, None) + object Field { + final val Leader = 1 + final val Task = 2 + final val ZoneId = 3 + final val Size = 4 + final val Capacity = 5 } } +/** + * An object that contains all of the logic necessary to transform between + * the various forms of squad information found in formulaic packet data structures + * and a singular `SquadInfo` object with only the important data fields that were defined. + */ object SquadHeader { - /** - * Alternate constructor for SquadInfo that ignores the Option requirement for the `info` field. - * @param unk1 na - * @param unk2 na - * @param unk3 na; not always defined - * @param info information pertaining to this squad listing - */ - def apply(unk1 : Int, unk2 : Boolean, unk3 : Option[Int], info : SquadInfo) : SquadHeader = { - SquadHeader(unk1, unk2, unk3, Some(info)) - } - - /** - * `squadPattern` completes the fields for the `SquadHeader` class. - * It translates an indeterminate number of bit regions into something that can be processed as an `Option[SquadInfo]`. - */ - private type squadPattern = Option[SquadInfo] :: HNil + //DO NOT IMPORT shapeless.:: TOP LEVEL TO THIS OBJECT - it interferes with required scala.collection.immutable.:: /** * `Codec` for reading `SquadInfo` data from the first entry from a packet with squad list initialization entries. */ - private val initCodec : Codec[squadPattern] = ( - ("squad_guid" | PlanetSideGUID.codec) :: + private val infoCodec : Codec[SquadInfo] = { + import shapeless.:: + (("squad_guid" | PlanetSideGUID.codec) :: ("leader" | PacketHelpers.encodedWideString) :: ("task" | PacketHelpers.encodedWideString) :: ("continent_guid" | PlanetSideZoneID.codec) :: ("size" | uint4L) :: ("capacity" | uint4L) - ).exmap[squadPattern] ( - { - case sguid :: lead :: tsk :: cguid :: sz :: cap :: HNil => - Attempt.successful(Some(SquadInfo(lead, tsk, cguid, sz, cap, sguid)) :: HNil) - case _ => - Attempt.failure(Err("failed to decode squad data for adding [A] a squad entry")) - }, - { - case Some(SquadInfo(Some(lead), Some(tsk), Some(cguid), Some(sz), Some(cap), Some(sguid))) :: HNil => - Attempt.successful(sguid :: lead :: tsk :: cguid :: sz :: cap :: HNil) - case _ => - Attempt.failure(Err("failed to encode squad data for adding [A] a squad entry")) - } - ) + ).exmap[SquadInfo]( + { + case sguid :: lead :: tsk :: cguid :: sz :: cap :: HNil => + Attempt.successful(SquadInfo(lead, tsk, cguid, sz, cap, sguid)) + case _ => + Attempt.failure(Err("failed to decode squad data for adding the initial squad entry")) + }, + { + case SquadInfo(Some(lead), Some(tsk), Some(cguid), Some(sz), Some(cap), Some(sguid)) => + Attempt.successful(sguid :: lead :: tsk :: cguid :: sz :: cap :: HNil) + case _ => + Attempt.failure(Err("failed to encode squad data for adding the initial squad entry")) + } + ) + } /** * `Codec` for reading `SquadInfo` data from all entries other than the first from a packet with squad list initialization entries. */ - private val alt_initCodec : Codec[squadPattern] = ( - ("squad_guid" | PlanetSideGUID.codec) :: - ("leader" | PacketHelpers.encodedWideStringAligned(7)) :: - ("task" | PacketHelpers.encodedWideString) :: - ("continent_guid" | PlanetSideZoneID.codec) :: - ("size" | uint4L) :: - ("capacity" | uint4L) - ).exmap[squadPattern] ( - { - case sguid :: lead :: tsk :: cguid :: sz :: cap :: HNil => - Attempt.successful(Some(SquadInfo(lead, tsk, cguid, sz, cap, sguid)) :: HNil) - case _ => - Attempt.failure(Err("failed to decode squad data for adding [B] a squad entry")) - }, - { - case Some(SquadInfo(Some(lead), Some(tsk), Some(cguid), Some(sz), Some(cap), Some(sguid))) :: HNil => - Attempt.successful(sguid :: lead :: tsk :: cguid :: sz :: cap :: HNil) - case _ => - Attempt.failure(Err("failed to encode squad data for adding [B] a squad entry")) - } - ) + private val alt_infoCodec : Codec[SquadInfo] = { + import shapeless.:: + ( + ("squad_guid" | PlanetSideGUID.codec) :: + ("leader" | PacketHelpers.encodedWideStringAligned(7)) :: + ("task" | PacketHelpers.encodedWideString) :: + ("continent_guid" | PlanetSideZoneID.codec) :: + ("size" | uint4L) :: + ("capacity" | uint4L) + ).exmap[SquadInfo] ( + { + case sguid :: lead :: tsk :: cguid :: sz :: cap :: HNil => + Attempt.successful(SquadInfo(lead, tsk, cguid, sz, cap, sguid)) + case _ => + Attempt.failure(Err("failed to decode squad data for adding an additional squad entry")) + }, + { + case SquadInfo(Some(lead), Some(tsk), Some(cguid), Some(sz), Some(cap), Some(sguid)) => + Attempt.successful(sguid :: lead :: tsk :: cguid :: sz :: cap :: HNil) + case _ => + Attempt.failure(Err("failed to encode squad data for adding an additional squad entry")) + } + ) + } /** * `Codec` for reading the `SquadInfo` data in an "update all squad data" entry. */ - private val allCodec : Codec[squadPattern] = ( - ("squad_guid" | PlanetSideGUID.codec) :: - ("leader" | PacketHelpers.encodedWideStringAligned(3)) :: - ("task" | PacketHelpers.encodedWideString) :: - ("continent_guid" | PlanetSideZoneID.codec) :: - ("size" | uint4L) :: - ("capacity" | uint4L) - ).exmap[squadPattern] ( - { - case sguid :: lead :: tsk :: cguid :: sz :: cap :: HNil => - Attempt.successful(Some(SquadInfo(lead, tsk, cguid, sz, cap, sguid)) :: HNil) - case _ => - Attempt.failure(Err("failed to decode squad data for updating a squad entry")) - }, - { - case Some(SquadInfo(Some(lead), Some(tsk), Some(cguid), Some(sz), Some(cap), Some(sguid))) :: HNil => - Attempt.successful(sguid :: lead :: tsk :: cguid :: sz :: cap :: HNil) - case _ => - Attempt.failure(Err("failed to encode squad data for updating a squad entry")) + private val allCodec : Codec[SquadInfo] = { + import shapeless.:: + ( + ("squad_guid" | PlanetSideGUID.codec) :: + ("leader" | PacketHelpers.encodedWideStringAligned(3)) :: + ("task" | PacketHelpers.encodedWideString) :: + ("continent_guid" | PlanetSideZoneID.codec) :: + ("size" | uint4L) :: + ("capacity" | uint4L) + ).exmap[SquadInfo] ( + { + case sguid :: lead :: tsk :: cguid :: sz :: cap :: HNil => + Attempt.successful(SquadInfo(lead, tsk, cguid, sz, cap, sguid)) + case _ => + Attempt.failure(Err("failed to decode squad data for updating a squad entry")) + }, + { + case SquadInfo(Some(lead), Some(tsk), Some(cguid), Some(sz), Some(cap), Some(sguid)) => + Attempt.successful(sguid :: lead :: tsk :: cguid :: sz :: cap :: HNil) + case _ => + Attempt.failure(Err("failed to encode squad data for updating a squad entry")) + } + ) + } + + /** + * Produces a `Codec` function for byte-aligned, padded Pascal strings encoded through common manipulations. + * @see `PacketHelpers.encodedWideStringAligned` + * @param over the number of bits past the previous byte-aligned index; + * should be a 0-7 number that gets converted to a 1-7 string padding number + * @return the encoded string `Codec` + */ + private def paddedStringMetaCodec(over : Int) : Codec[String] = PacketHelpers.encodedWideStringAligned({ + val mod8 = over % 8 + if(mod8 == 0) { + 0 } - ) + else { + 8 - mod8 + } + }) /** * `Codec` for reading the `SquadInfo` data in an "update squad leader" entry. */ - private val leaderCodec : Codec[squadPattern] = ( - bool :: - ("leader" | PacketHelpers.encodedWideStringAligned(7)) - ).exmap[squadPattern] ( + private def leaderCodec(over : Int) : Codec[SquadInfo] = paddedStringMetaCodec(over).exmap[SquadInfo] ( + lead => Attempt.successful(SquadInfo(lead)), { - case true :: lead :: HNil => - Attempt.successful(Some(SquadInfo(lead, None)) :: HNil) - case _ => - Attempt.failure(Err("failed to decode squad data for a leader name")) - }, - { - case Some(SquadInfo(Some(lead), _, _, _, _, _)) :: HNil => - Attempt.successful(true :: lead :: HNil) + case SquadInfo(Some(lead), _, _, _, _, _) => + Attempt.successful(lead) case _ => Attempt.failure(Err("failed to encode squad data for a leader name")) } ) /** - * `Codec` for reading the `SquadInfo` data in an "update squad task or continent" entry. + * `Codec` for reading the `SquadInfo` data in an "update task text" entry. */ - private val taskOrContinentCodec : Codec[squadPattern] = ( - bool >>:~ { path => - conditional(path, "continent_guid" | PlanetSideZoneID.codec) :: - conditional(!path, "task" | PacketHelpers.encodedWideStringAligned(7)) - } - ).exmap[squadPattern] ( + private def taskCodec(over : Int) : Codec[SquadInfo] = paddedStringMetaCodec(over).exmap[SquadInfo] ( + task => Attempt.successful(SquadInfo(None, task)), { - case true :: Some(cguid) :: _ :: HNil => - Attempt.successful(Some(SquadInfo(cguid)) :: HNil) - case true :: None :: _ :: HNil => - Attempt.failure(Err("failed to decode squad data for a task - no continent")) - case false :: _ :: Some(tsk) :: HNil => - Attempt.successful(Some(SquadInfo(None, tsk)) :: HNil) - case false :: _ :: None :: HNil => - Attempt.failure(Err("failed to decode squad data for a task - no task")) - }, - { - case Some(SquadInfo(_, None, Some(cguid), _, _, _)) :: HNil => - Attempt.successful(true :: Some(cguid) :: None :: HNil) - case Some(SquadInfo(_, Some(tsk), None, _, _, _)) :: HNil => - Attempt.successful(false :: None :: Some(tsk) :: HNil) - case Some(SquadInfo(_, Some(_), Some(_), _, _, _)) :: HNil => - Attempt.failure(Err("failed to encode squad data for either a task or a continent - multiple encodings reachable")) + case SquadInfo(_, Some(task), _, _, _, _) => + Attempt.successful(task) case _ => - Attempt.failure(Err("failed to encode squad data for either a task or a continent")) + Attempt.failure(Err("failed to encode squad data for a task string")) + } + ) + + /** + * `Codec` for reading the `SquadInfo` data in an "update squad zone id" entry. + * In reality, the "zone's id" is the zone's server ordinal index. + */ + private val zoneIdCodec : Codec[SquadInfo] = PlanetSideZoneID.codec.exmap[SquadInfo] ( + cguid => Attempt.successful(SquadInfo(cguid)), + { + case SquadInfo(_, _, Some(cguid), _, _, _) => + Attempt.successful(cguid) + case _ => + Attempt.failure(Err("failed to encode squad data for a continent number")) } ) /** * `Codec` for reading the `SquadInfo` data in an "update squad size" entry. */ - private val sizeCodec : Codec[squadPattern] = ( - bool :: - ("size" | uint4L) - ).exmap[squadPattern] ( + private val sizeCodec : Codec[SquadInfo] = uint4L.exmap[SquadInfo] ( + sz => Attempt.successful(SquadInfo(sz)), { - case false :: sz :: HNil => - Attempt.successful(Some(SquadInfo(sz, None)) :: HNil) + case SquadInfo(_, _, _, Some(sz), _, _) => + Attempt.successful(sz) case _ => - Attempt.failure(Err("failed to decode squad data for a size")) - }, - { - case Some(SquadInfo(_, _, _, Some(sz), _, _)) :: HNil => - Attempt.successful(false :: sz :: HNil) - case _ => - Attempt.failure(Err("failed to encode squad data for a size")) + Attempt.failure(Err("failed to encode squad data for squad size")) } ) /** - * `Codec` for reading the `SquadInfo` data in an "update squad leader and size" entry. + * `Codec` for reading the `SquadInfo` data in an "update squad capacity" entry. */ - private val leaderSizeCodec : Codec[squadPattern] = ( - bool :: - ("leader" | PacketHelpers.encodedWideStringAligned(7)) :: - uint4L :: - ("size" | uint4L) - ).exmap[squadPattern] ( + private val capacityCodec : Codec[SquadInfo] = uint4L.exmap[SquadInfo] ( + cap => Attempt.successful(SquadInfo(None, cap)), { - case true :: lead :: 4 :: sz :: HNil => - Attempt.successful(Some(SquadInfo(lead, sz)) :: HNil) + case SquadInfo(_, _, _, _, Some(cap), _) => + Attempt.successful(cap) case _ => - Attempt.failure(Err("failed to decode squad data for a leader and a size")) - }, - { - case Some(SquadInfo(Some(lead), _, _, Some(sz), _, _)) :: HNil => - Attempt.successful(true :: lead :: 4 :: sz :: HNil) - case _ => - Attempt.failure(Err("failed to encode squad data for a leader and a size")) + Attempt.failure(Err("failed to encode squad data for squad capacity")) } ) /** - * `Codec` for reading the `SquadInfo` data in an "update squad task and continent" entry. + * `Codec` for reading the `SquadInfo` data in a "remove squad from list" entry. + * While the input has no impact, it always writes the number four to a `3u` field - or `0x100`. */ - private val taskAndContinentCodec : Codec[squadPattern] = ( - bool :: - ("task" | PacketHelpers.encodedWideStringAligned(7)) :: - uintL(3) :: - bool :: - ("continent_guid" | PlanetSideZoneID.codec) - ).exmap[squadPattern] ( - { - case false :: tsk :: 1 :: true :: cguid :: HNil => - Attempt.successful(Some(SquadInfo(tsk, cguid)) :: HNil) - case _ => - Attempt.failure(Err("failed to decode squad data for a task and a continent")) - }, - { - case Some(SquadInfo(_, Some(tsk), Some(cguid), _, _, _)) :: HNil => - Attempt.successful(false :: tsk :: 1 :: true :: cguid :: HNil) - case _ => - Attempt.failure(Err("failed to encode squad data for a task and a continent")) - } - ) - - /** - * Codec for reading the `SquadInfo` data in a "remove squad from list" entry. - * This `Codec` is unique because it is considered a valid `Codec` that does not read any bit data. - * The `conditional` will always return `None` because its determining conditional statement is explicitly `false`. - */ - private val removeCodec : Codec[squadPattern] = conditional(false, bool).exmap[squadPattern] ( - { - case None | _ => - Attempt.successful(None :: HNil) - }, - { - case None :: HNil | _ => - Attempt.successful(None) - } + private val removeCodec : Codec[SquadInfo] = uint(3).exmap[SquadInfo] ( + _ => Attempt.successful(SquadInfo.Blank), + _ => Attempt.successful(4) ) /** * `Codec` for failing to determine a valid `Codec` based on the entry data. * This `Codec` is an invalid codec that does not read any bit data. - * The `conditional` will always return `None` because its determining conditional statement is explicitly `false`. + * The `conditional` will always return `None` because + * its determining conditional statement is explicitly `false` + * and all cases involving explicit failure. */ - private val failureCodec : Codec[squadPattern] = conditional(false, bool).exmap[squadPattern] ( - { - case None | _ => - Attempt.failure(Err("decoding with unhandled codec")) - }, - { - case None :: HNil | _ => - Attempt.failure(Err("encoding with unhandled codec")) - } + private val failureCodec : Codec[SquadInfo] = conditional(included = false, bool).exmap[SquadInfo] ( + _ => Attempt.failure(Err("decoding with unhandled codec")), + _ => Attempt.failure(Err("encoding with unhandled codec")) ) /** - * Select the `Codec` to translate bit data in this packet into an `Option[SquadInfo]` using other fields of the same packet. - * Refer to comments for the primary `case class` constructor for `SquadHeader` to explain how the conditions in this function path. - * @param a na - * @param b na - * @param c na; may be `None` - * @param optionalCodec a to-be-defined `Codec` that is determined by the suggested mood of the packet and listing of the squad; - * despite the name, actually a required parameter - * @return a `Codec` that corresponds to a `squadPattern` translation + * An internal class that assists in the process of transforming squad data + * encoded in the "update" format of a `ReplicationStreamMessage` packet as index-coded fields + * into a singular decoded `SquadInfo` object that is populated with all of the previously-discovered fields. + * @param code the field code for the current data + * @param info the current squad data + * @param next a potential next encoded squad field */ - private def selectCodec(a : Int, b : Boolean, c : Option[Int], optionalCodec : Codec[squadPattern]) : Codec[squadPattern] = { - if(c.isDefined) { - val cVal = c.get - if(a == 0 && b) - if(cVal == 4) - return removeCodec - if(a == 128 && b) { - if(cVal == 0) - return leaderCodec - else if(cVal == 1) - return taskOrContinentCodec - else if(cVal == 2) - return sizeCodec - } - else if(a == 129 && !b) { - if(cVal == 0) - return leaderSizeCodec - else if(cVal == 1) - return taskAndContinentCodec - } + private final case class LinkedSquadInfo(code : Int, info : SquadInfo, next : Option[LinkedSquadInfo]) + + /** + * Concatenate a `SquadInfo` object chain into a single `SquadInfo` object. + * @param info the chain + * @return the concatenated `SquadInfo` object + */ + private def unlinkSquadInfo(info : LinkedSquadInfo) : SquadInfo = unlinkSquadInfo(Some(info)) + + /** + * Concatenate a `SquadInfo` object chain into a single `SquadInfo` object. + * Recursively visits every link in a `SquadInfo` object chain. + * @param info the current link in the chain + * @param squadInfo the persistent `SquadInfo` concatenation object; + * defaults to `SquadInfo.Blank` + * @return the concatenated `SquadInfo` object + */ + @tailrec + private def unlinkSquadInfo(info : Option[LinkedSquadInfo], squadInfo : SquadInfo = SquadInfo.Blank) : SquadInfo = { + info match { + case None => + squadInfo + case Some(sqInfo) => + unlinkSquadInfo(sqInfo.next, squadInfo And sqInfo.info) + } + } + + /** + * Decompose a single `SquadInfo` object into a `SquadInfo` object chain of the original's fields. + * The fields as a linked list are explicitly organized "leader", "task", "zone_id", "size", "capacity," + * or as "(leader, (task, (zone_id, (size, (capacity, None)))))" when fully populated and composed. + * @param info a `SquadInfo` object that has all relevant fields populated + * @return a linked list of `SquadInfo` objects, each with a single field from the input `SquadInfo` object + */ + private def linkSquadInfo(info : SquadInfo) : LinkedSquadInfo = { + //import scala.collection.immutable.:: + Seq( + (SquadInfo.Field.Capacity, SquadInfo(None, None, None, None, info.capacity)), + (SquadInfo.Field.Size, SquadInfo(None, None, None, info.size, None)), + (SquadInfo.Field.ZoneId, SquadInfo(None, None, info.zone_id, None, None)), + (SquadInfo.Field.Task, SquadInfo(None, info.task, None, None, None)), + (SquadInfo.Field.Leader, SquadInfo(info.leader, None, None, None, None)) + ) //in reverse order so that the linked list is in the correct order + .filterNot { case (_, sqInfo) => sqInfo == SquadInfo.Blank } + match { + case Nil => + throw new Exception("no linked list squad fields encountered where at least one was expected") //bad end + case x :: Nil => + val (code, squadInfo) = x + LinkedSquadInfo(code, squadInfo, None) + case x :: xs => + val (code, squadInfo) = x + linkSquadInfo(xs, LinkedSquadInfo(code, squadInfo, None)) + } + } + + /** + * Decompose a single `SquadInfo` object into a `SquadInfo` object chain of the original's fields. + * The fields as a linked list are explicitly organized "leader", "task", "zone_id", "size", "capacity," + * or as "(leader, (task, (zone_id, (size, (capacity, None)))))" when fully populated and composed. + * @param infoList a series of paired field codes and `SquadInfo` objects with data in the indicated fields + * @return a linked list of `SquadInfo` objects, each with a single field from the input `SquadInfo` object + */ + @tailrec + private def linkSquadInfo(infoList : Seq[(Int, SquadInfo)], linkedInfo : LinkedSquadInfo) : LinkedSquadInfo = { + if(infoList.isEmpty) { + linkedInfo } else { - if(a == 131 && !b) - return optionalCodec + val (code, data) = infoList.head + linkSquadInfo(infoList.tail, LinkedSquadInfo(code, data, Some(linkedInfo))) } - //we've not encountered a valid Codec - failureCodec + } + + /** + * Parse a known number of knowable format data fields for squad info + * until no more fields are left for parsing. + * @see `selectCodecAction` + * @see `modifyPadValue` + * @param size the total number of data fields to parse + * @param pad the current overflow/padding value + * @return a `LinkedSquadInfo` `Codec` object (linked list) + */ + private def listing_codec(size : Int, pad : Int = 1) : Codec[LinkedSquadInfo] = { + import shapeless.:: + ( + uint4 >>:~ { code => + selectCodecAction(code, pad) :: + conditional(size - 1 > 0, listing_codec(size - 1, (pad + modifyPadValue(code, pad)) % 8)) + } + ).xmap[LinkedSquadInfo] ( + { + case code :: entry :: next :: HNil => + LinkedSquadInfo(code, entry, next) + }, + { + case LinkedSquadInfo(code, entry, next) => + code :: entry :: next :: HNil + } + ) + } + + /** + * Given the field code value of the current `SquadInfo` object's data, + * select the `Codec` object that is most suitable to parse that data. + * @param code the field code number + * @param pad the current overflow/padding value; + * the number of bits past the previous byte-aligned index; + * should be a 0-7 number that gets converted to a 1-7 string padding number + * @return a `Codec` object for the specific field's data + */ + private def selectCodecAction(code : Int, pad : Int) : Codec[SquadInfo] = { + code match { + case SquadInfo.Field.Leader => leaderCodec(pad) + case SquadInfo.Field.Task => taskCodec(pad) + case SquadInfo.Field.ZoneId => zoneIdCodec + case SquadInfo.Field.Size => sizeCodec + case SquadInfo.Field.Capacity => capacityCodec + case _ => failureCodec + } + } + + /** + * Given the field code value of the current `SquadInfo` object's data, + * determine how the inherited overflow/padding value for string data should be adjusted for future entries. + * There are three paths: becomes zero, doesn't change, or increases by four units. + * The invalid condition leads to an extremely negative number, but this condition should also never be encountered. + * @param code the field code number + * @param pad the current overflow/padding value; + * the number of bits past the previous byte-aligned index; + * should be a 0-7 number that gets converted to a 1-7 string padding number + * @return the number of units that the current overflow/padding value should be modified, in terms of addition + */ + private def modifyPadValue(code : Int, pad : Int) : Int = { + code match { + case SquadInfo.Field.Leader => -pad //byte-aligned string; padding zero'd + case SquadInfo.Field.Task => -pad //byte-aligned string; padding zero'd + case SquadInfo.Field.ZoneId => 4 //4u + 32u = 4u + 8*4u = additional 4u + case SquadInfo.Field.Size => 0 //4u + 4u = no change to padding + case SquadInfo.Field.Capacity => 0 //4u + 4u = no change to padding + case _ => Int.MinValue //wildly incorrect + } + } + + /** + * The framework for transforming data from squad listing entries. + * Three paths lead from this position: + * processing the data in the course of an entry removal action, + * processing the data in the course of a total squad listing initialization action, and + * processing the data of a single entry in a piecemeal fashion that parses a coded field-by-field format. + * For the second - initialization - another `Codec` object is utilized to determine how the data should be interpreted. + * @param providedCodec the `Codec` for processing a `SquadInfo` object during the squad list initialization process + * @return a `SquadListing` `Codec` object + */ + private def meta_codec(providedCodec : Codec[SquadInfo]) : Codec[Option[SquadInfo]] = { + import shapeless.:: + ( + bool >>:~ { unk1 => + uint8 >>:~ { unk2 => + conditional(!unk1 && unk2 == 1, removeCodec) :: + conditional(unk1 && unk2 == 6, providedCodec) :: + conditional(unk1 && unk2 != 6, listing_codec(unk2)) + } + }).exmap[Option[SquadInfo]] ( + { + case false :: 1 :: Some(SquadInfo.Blank) :: None :: None :: HNil => //'remove' case + Attempt.Successful( None ) + + case true :: 6 :: None :: Some(info) :: None :: HNil => //handle complete squad info; no field codes + Attempt.Successful( Some(info) ) + + case true :: _ :: None :: None:: Some(result) :: HNil => //iterable field codes + Attempt.Successful( Some(unlinkSquadInfo(result)) ) + + case data => //error + Attempt.failure(Err(s"$data can not be encoded as a squad header")) + }, + { + case None => //'remove' case + Attempt.Successful( false :: 1 :: Some(SquadInfo.Blank) :: None :: None :: HNil ) + + case info @ Some(SquadInfo(Some(_), Some(_), Some(_), Some(_), Some(_), Some(_))) => //handle complete squad info; no field codes + Attempt.Successful( true :: 6 :: None :: info :: None :: HNil ) + + case Some(info) => //iterable field codes + val linkedInfo = linkSquadInfo(info) + var count = 1 + var linkNext = linkedInfo.next + while(linkNext.nonEmpty) { + count += 1 + linkNext = linkNext.get.next + } + Attempt.Successful( true :: count :: None :: None :: Some(linkSquadInfo(info)) :: HNil ) + + case data => //error + Attempt.failure(Err(s"$data can not be decoded into a squad header")) + } + ) } /** * `Codec` for standard `SquadHeader` entries. */ - val codec : Codec[SquadHeader] = ( - ("unk1" | uint8L) >>:~ { unk1 => - ("unk2" | bool) >>:~ { unk2 => - conditional(unk1 != 131, "unk3" | uintL(3)) >>:~ { unk3 => - selectCodec(unk1, unk2, unk3, allCodec) - } - } - }).as[SquadHeader] + val codec : Codec[Option[SquadInfo]] = meta_codec(allCodec) /** * `Codec` for types of `SquadHeader` initializations. */ - val init_codec : Codec[SquadHeader] = ( - ("unk1" | uint8L) >>:~ { unk1 => - ("unk2" | bool) >>:~ { unk2 => - conditional(unk1 != 131, "unk3" | uintL(3)) >>:~ { unk3 => - selectCodec(unk1, unk2, unk3, initCodec) - } - } - }).as[SquadHeader] + val info_codec : Codec[Option[SquadInfo]] = meta_codec(infoCodec) /** * Alternate `Codec` for types of `SquadHeader` initializations. */ - val alt_init_codec : Codec[SquadHeader] = ( - ("unk1" | uint8L) >>:~ { unk1 => - ("unk2" | bool) >>:~ { unk2 => - conditional(unk1 != 131, "unk3" | uintL(3)) >>:~ { unk3 => - selectCodec(unk1, unk2, unk3, alt_initCodec) - } - } - }).as[SquadHeader] + val alt_info_codec : Codec[Option[SquadInfo]] = meta_codec(alt_infoCodec) } object SquadListing { + import shapeless.:: + /** - * `Codec` for standard `SquadListing` entries. + * Overloaded constructor for guaranteed squad information. + * @param index the listing entry index for this squad + * @param info the squad data + * @return a `SquadListing` object */ - val codec : Codec[SquadListing] = ( + def apply(index : Int, info : SquadInfo) : SquadListing = { + SquadListing(index, Some(info)) + } + + /** + * The framework for transforming data from squad listing entries. + * @param entryFunc the `Codec` for processing a given `SquadListing` object + * @return a `SquadListing` `Codec` object + */ + private def meta_codec(entryFunc : Int=>Codec[Option[SquadInfo]]) : Codec[SquadListing] = ( ("index" | uint8L) >>:~ { index => - conditional(index < 255, "listing" | SquadHeader.codec) :: - conditional(index == 255, bits) //consume n < 8 bits padding the tail entry, else vector will try to operate on invalid data + conditional(index < 255, "listing" | entryFunc(index)) :: + conditional(index == 255, bits) //consume n < 8 bits after the tail entry, else vector will try to operate on invalid data }).xmap[SquadListing] ( { - case ndx :: lstng :: _ :: HNil => + case ndx :: Some(lstng) :: _ :: HNil => SquadListing(ndx, lstng) + case ndx :: None :: _ :: HNil => + SquadListing(ndx, None) }, { case SquadListing(ndx, lstng) => - ndx :: lstng :: None :: HNil + ndx :: Some(lstng) :: None :: HNil } ) + /** + * `Codec` for standard `SquadListing` entries. + */ + val codec : Codec[SquadListing] = meta_codec({ _ => SquadHeader.codec }) + /** * `Codec` for branching types of `SquadListing` initializations. */ - val init_codec : Codec[SquadListing] = ( - ("index" | uint8L) >>:~ { index => - conditional(index < 255, - newcodecs.binary_choice(index == 0, - "listing" | SquadHeader.init_codec, - "listing" | SquadHeader.alt_init_codec) - ) :: - conditional(index == 255, bits) //consume n < 8 bits padding the tail entry, else vector will try to operate on invalid data - }).xmap[SquadListing] ( - { - case ndx :: lstng :: _ :: HNil => - SquadListing(ndx, lstng) - }, - { - case SquadListing(ndx, lstng) => - ndx :: lstng :: None :: HNil - } - ) + val info_codec : Codec[SquadListing] = meta_codec({ index : Int => + newcodecs.binary_choice(index == 0, + "listing" | SquadHeader.info_codec, + "listing" | SquadHeader.alt_info_codec + ) + }) } object ReplicationStreamMessage extends Marshallable[ReplicationStreamMessage] { + import shapeless.:: + + /** + * A shortcut for the squad list initialization format of the packet. + * Supplied squad data is given a zero-indexed counter and transformed into formal "`Listing`s" for processing. + * @param infos the squad data to be composed into formal list entries + * @return a `ReplicationStreamMessage` packet object + */ + def apply(infos : Iterable[SquadInfo]) : ReplicationStreamMessage = { + ReplicationStreamMessage(5, Some(6), infos + .zipWithIndex + .map { case (info, index) => SquadListing(index, Some(info)) } + .toVector + ) + } + implicit val codec : Codec[ReplicationStreamMessage] = ( ("behavior" | uintL(3)) >>:~ { behavior => conditional(behavior == 5, "behavior2" | uintL(3)) :: conditional(behavior != 1, bool) :: newcodecs.binary_choice(behavior != 5, "entries" | vector(SquadListing.codec), - "entries" | vector(SquadListing.init_codec) + "entries" | vector(SquadListing.info_codec) ) }).xmap[ReplicationStreamMessage] ( { case bhvr :: bhvr2 :: _ :: lst :: HNil => - ReplicationStreamMessage(bhvr, bhvr2, lst) + ReplicationStreamMessage(bhvr, bhvr2, ignoreTerminatingEntry(lst)) }, { case ReplicationStreamMessage(1, bhvr2, lst) => - 1 :: bhvr2 :: None :: lst :: HNil + 1 :: bhvr2 :: None :: ensureTerminatingEntry(lst) :: HNil case ReplicationStreamMessage(bhvr, bhvr2, lst) => - bhvr :: bhvr2 :: Some(false) :: lst :: HNil + bhvr :: bhvr2 :: Some(false) :: ensureTerminatingEntry(lst) :: HNil } ) + + /** + * The last entry in the sequence of squad information listings should be a dummied listing with an index of 255. + * Ensure that this terminal entry is located at the end. + * @param list the listing of squad information + * @return the listing of squad information, with a specific final entry + */ + private def ensureTerminatingEntry(list : Vector[SquadListing]) : Vector[SquadListing] = { + list.lastOption match { + case Some(SquadListing(255, _)) => list + case Some(_) | None => list :+ SquadListing() + } + } + + /** + * The last entry in the sequence of squad information listings should be a dummied listing with an index of 255. + * Remove this terminal entry from the end of the list so as not to hassle with it. + * @param list the listing of squad information + * @return the listing of squad information, with a specific final entry truncated + */ + private def ignoreTerminatingEntry(list : Vector[SquadListing]) : Vector[SquadListing] = { + list.lastOption match { + case Some(SquadListing(255, _)) => list.init + case Some(_) | None => list + } + } } /* - +-> SquadListing.codec -------> SquadHeader.codec ----------+ - | | - | | -ReplicationStream.codec -+ | - | | - | +-> SquadHeader.init_codec -----+-> SquadInfo - | | | - +-> SquadListing.initCodec -+ | - | | - +-> SquadHeader.alt_init_codec -+ + +-> SquadListing.codec --------> SquadHeader.codec ----------+ + | | + | | +ReplicationStream.codec -+ | + | | + | +-> SquadHeader.info_codec -----+-> SquadInfo + | | | + +-> SquadListing.info_codec -+ | + | | + +-> SquadHeader.alt_info_codec -+ */ diff --git a/common/src/main/scala/net/psforever/packet/game/SquadDefinitionActionMessage.scala b/common/src/main/scala/net/psforever/packet/game/SquadDefinitionActionMessage.scala index 052c0e55e..ccdc41b5d 100644 --- a/common/src/main/scala/net/psforever/packet/game/SquadDefinitionActionMessage.scala +++ b/common/src/main/scala/net/psforever/packet/game/SquadDefinitionActionMessage.scala @@ -2,26 +2,375 @@ package net.psforever.packet.game import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket} +import net.psforever.types.CertificationType +import scodec.bits.BitVector import scodec.{Attempt, Codec, Err} import scodec.codecs._ import shapeless.{::, HNil} +/** + * The generic superclass of a specific behavior for this type of squad definition action. + * All behaviors have a "code" that indicates how the rest of the data is parsed. + * @param code the action behavior code + */ +abstract class SquadAction(val code : Int) + +object SquadAction{ + object SearchMode extends Enumeration { + type Type = Value + + val + AnyPositions, + AvailablePositions, + SomeCertifications, + AllCertifications + = Value + + implicit val codec : Codec[SearchMode.Value] = PacketHelpers.createEnumerationCodec(enum = this, uint(bits = 3)) + } + + final case class DisplaySquad() extends SquadAction(0) + + /** + * Dispatched from client to server to indicate a squad detail update that has no foundation entry to update? + * Not dissimilar from `DisplaySquad`. + */ + final case class SquadMemberInitializationIssue() extends SquadAction(1) + + final case class SaveSquadFavorite() extends SquadAction(3) + + final case class LoadSquadFavorite() extends SquadAction(4) + + final case class DeleteSquadFavorite() extends SquadAction(5) + + final case class ListSquadFavorite(name : String) extends SquadAction(7) + + final case class RequestListSquad() extends SquadAction(8) + + final case class StopListSquad() extends SquadAction(9) + + final case class SelectRoleForYourself(state : Int) extends SquadAction(10) + + final case class CancelSelectRoleForYourself(value: Long = 0) extends SquadAction(15) + + final case class AssociateWithSquad() extends SquadAction(16) + + final case class SetListSquad() extends SquadAction(17) + + final case class ChangeSquadPurpose(purpose : String) extends SquadAction(19) + + final case class ChangeSquadZone(zone : PlanetSideZoneID) extends SquadAction(20) + + final case class CloseSquadMemberPosition(position : Int) extends SquadAction(21) + + final case class AddSquadMemberPosition(position : Int) extends SquadAction(22) + + final case class ChangeSquadMemberRequirementsRole(u1 : Int, role : String) extends SquadAction(23) + + final case class ChangeSquadMemberRequirementsDetailedOrders(u1 : Int, orders : String) extends SquadAction(24) + + final case class ChangeSquadMemberRequirementsCertifications(u1 : Int, certs : Set[CertificationType.Value]) extends SquadAction(25) + + final case class ResetAll() extends SquadAction(26) + + final case class AutoApproveInvitationRequests(state : Boolean) extends SquadAction(28) + + final case class LocationFollowsSquadLead(state : Boolean) extends SquadAction(31) + + final case class SearchForSquadsWithParticularRole(role: String, requirements : Set[CertificationType.Value], zone_id: Int, mode : SearchMode.Value) extends SquadAction(34) + + final case class CancelSquadSearch() extends SquadAction(35) + + final case class AssignSquadMemberToRole(position : Int, char_id : Long) extends SquadAction(38) + + final case class NoSquadSearchResults() extends SquadAction(39) + + final case class FindLfsSoldiersForRole(state : Int) extends SquadAction(40) + + final case class CancelFind() extends SquadAction(41) + + final case class Unknown(badCode : Int, data : BitVector) extends SquadAction(badCode) + + object Unknown { + import scodec.bits._ + val StandardBits : BitVector = hex"00".toBitVector.take(6) + + def apply(badCode : Int) : Unknown = Unknown(badCode, StandardBits) + } + + /** + * The `Codec`s used to transform the input stream into the context of a specific action + * and extract the field data from that stream. + */ + object Codecs { + private val everFailCondition = conditional(included = false, bool) + + val displaySquadCodec = everFailCondition.xmap[DisplaySquad] ( + _ => DisplaySquad(), + { + case DisplaySquad() => None + } + ) + + val squadMemberInitializationIssueCodec = everFailCondition.xmap[SquadMemberInitializationIssue] ( + _ => SquadMemberInitializationIssue(), + { + case SquadMemberInitializationIssue() => None + } + ) + + val saveSquadFavoriteCodec = everFailCondition.xmap[SaveSquadFavorite] ( + _ => SaveSquadFavorite(), + { + case SaveSquadFavorite() => None + } + ) + + val loadSquadFavoriteCodec = everFailCondition.xmap[LoadSquadFavorite] ( + _ => LoadSquadFavorite(), + { + case LoadSquadFavorite() => None + } + ) + + val deleteSquadFavoriteCodec = everFailCondition.xmap[DeleteSquadFavorite] ( + _ => DeleteSquadFavorite(), + { + case DeleteSquadFavorite() => None + } + ) + + val listSquadFavoriteCodec = PacketHelpers.encodedWideStringAligned(6).xmap[ListSquadFavorite] ( + text => ListSquadFavorite(text), + { + case ListSquadFavorite(text) => text + } + ) + + val requestListSquadCodec = everFailCondition.xmap[RequestListSquad] ( + _ => RequestListSquad(), + { + case RequestListSquad() => None + } + ) + + val stopListSquadCodec = everFailCondition.xmap[StopListSquad] ( + _ => StopListSquad(), + { + case StopListSquad() => None + } + ) + + val selectRoleForYourselfCodec = uint4.xmap[SelectRoleForYourself] ( + value => SelectRoleForYourself(value), + { + case SelectRoleForYourself(value) => value + } + ) + + val cancelSelectRoleForYourselfCodec = uint32.xmap[CancelSelectRoleForYourself] ( + value => CancelSelectRoleForYourself(value), + { + case CancelSelectRoleForYourself(value) => value + } + ) + + val associateWithSquadCodec = everFailCondition.xmap[AssociateWithSquad] ( + _ => AssociateWithSquad(), + { + case AssociateWithSquad() => None + } + ) + + val setListSquadCodec = everFailCondition.xmap[SetListSquad] ( + _ => SetListSquad(), + { + case SetListSquad() => None + } + ) + + val changeSquadPurposeCodec = PacketHelpers.encodedWideStringAligned(6).xmap[ChangeSquadPurpose] ( + purpose => ChangeSquadPurpose(purpose), + { + case ChangeSquadPurpose(purpose) => purpose + } + ) + + val changeSquadZoneCodec = uint16L.xmap[ChangeSquadZone] ( + value => ChangeSquadZone(PlanetSideZoneID(value)), + { + case ChangeSquadZone(value) => value.zoneId.toInt + } + ) + + val closeSquadMemberPositionCodec = uint4.xmap[CloseSquadMemberPosition] ( + position => CloseSquadMemberPosition(position), + { + case CloseSquadMemberPosition(position) => position + } + ) + + val addSquadMemberPositionCodec = uint4.xmap[AddSquadMemberPosition] ( + position => AddSquadMemberPosition(position), + { + case AddSquadMemberPosition(position) => position + } + ) + + val changeSquadMemberRequirementsRoleCodec = (uint4L :: PacketHelpers.encodedWideStringAligned(2)).xmap[ChangeSquadMemberRequirementsRole] ( + { + case u1 :: role :: HNil => ChangeSquadMemberRequirementsRole(u1, role) + }, + { + case ChangeSquadMemberRequirementsRole(u1, role) => u1 :: role :: HNil + } + ) + + val changeSquadMemberRequirementsDetailedOrdersCodec = (uint4L :: PacketHelpers.encodedWideStringAligned(2)).xmap[ChangeSquadMemberRequirementsDetailedOrders] ( + { + case u1 :: role :: HNil => ChangeSquadMemberRequirementsDetailedOrders(u1, role) + }, + { + case ChangeSquadMemberRequirementsDetailedOrders(u1, role) => u1 :: role :: HNil + } + ) + + val changeSquadMemberRequirementsCertificationsCodec = (uint4 :: ulongL(46)).xmap[ChangeSquadMemberRequirementsCertifications] ( + { + case u1 :: u2 :: HNil => + ChangeSquadMemberRequirementsCertifications(u1, CertificationType.fromEncodedLong(u2)) + }, + { + case ChangeSquadMemberRequirementsCertifications(u1, u2) => + u1 :: CertificationType.toEncodedLong(u2) :: HNil + } + ) + + val resetAllCodec = everFailCondition.xmap[ResetAll] ( + _ => ResetAll(), + { + case ResetAll() => None + } + ) + + val autoApproveInvitationRequestsCodec = bool.xmap[AutoApproveInvitationRequests] ( + state => AutoApproveInvitationRequests(state), + { + case AutoApproveInvitationRequests(state) => state + } + ) + + val locationFollowsSquadLeadCodec = bool.xmap[LocationFollowsSquadLead] ( + state => LocationFollowsSquadLead(state), + { + case LocationFollowsSquadLead(state) => state + } + ) + + val searchForSquadsWithParticularRoleCodec = ( + PacketHelpers.encodedWideStringAligned(6) :: + ulongL(46) :: + uint16L :: + SearchMode.codec).xmap[SearchForSquadsWithParticularRole] ( + { + case u1 :: u2 :: u3 :: u4 :: HNil => SearchForSquadsWithParticularRole(u1, CertificationType.fromEncodedLong(u2), u3, u4) + }, + { + case SearchForSquadsWithParticularRole(u1, u2, u3, u4) => u1 :: CertificationType.toEncodedLong(u2) :: u3 :: u4 :: HNil + } + ) + + val cancelSquadSearchCodec = everFailCondition.xmap[CancelSquadSearch] ( + _ => CancelSquadSearch(), + { + case CancelSquadSearch() => None + } + ) + + val assignSquadMemberToRoleCodec = (uint4 :: uint32L).xmap[AssignSquadMemberToRole] ( + { + case u1 :: u2 :: HNil => AssignSquadMemberToRole(u1, u2) + }, + { + case AssignSquadMemberToRole(u1, u2) => u1 :: u2 :: HNil + } + ) + + val noSquadSearchResultsCodec = everFailCondition.xmap[NoSquadSearchResults] ( + _ => NoSquadSearchResults(), + { + case NoSquadSearchResults() => None + } + ) + + val findLfsSoldiersForRoleCodec = uint4.xmap[FindLfsSoldiersForRole] ( + state => FindLfsSoldiersForRole(state), + { + case FindLfsSoldiersForRole(state) => state + } + ) + + val cancelFindCodec = everFailCondition.xmap[CancelFind] ( + _ => CancelFind(), + { + case CancelFind() => None + } + ) + + /** + * A common form for known action code indexes with an unknown purpose and transformation is an "Unknown" object. + * @param action the action behavior code + * @return a transformation between the action code and the unknown bit data + */ + def unknownCodec(action : Int) = bits.xmap[Unknown] ( + data => Unknown(action, data), + { + case Unknown(_, data) => data + } + ) + + /** + * The action code was completely unanticipated! + * @param action the action behavior code + * @return nothing; always fail + */ + def failureCodec(action : Int)= everFailCondition.exmap[SquadAction] ( + _ => Attempt.failure(Err(s"can not match a codec pattern for decoding $action")), + _ => Attempt.failure(Err(s"can not match a codec pattern for encoding $action")) + ) + } +} + /** * Manage composition and details of a player's current squad, or the currently-viewed squad.
*
* The `action` code indicates the format of the remainder data in the packet. * The following formats are translated; their purposes are listed:
*   `(None)`
- *     `3 ` - Save Squad Definition - *     `8 ` - List Squad - *     `26` - Reset All - *     `35` - Cancel Squad Search - *     `41` - Cancel Find + *     `0 ` - Display Squad
+ *     `1 ` - Answer Squad Join Request
+ *     `2 ` - UNKNOWN
+ *     `3 ` - Save Squad Favorite
+ *     `4 ` - Load Squad Favorite
+ *     `5 ` - Delete Squad Favorite
+ *     `6 ` - UNKNOWN
+ *     `8 ` - Request List Squad
+ *     `9 ` - Stop List Squad
+ *     `16` - Associate with Squad
+ *     `17` - Set List Squad (ui)
+ *     `18` - UNKNOWN
+ *     `26` - Reset All
+ *     `32` - UNKNOWN
+ *     `35` - Cancel Squad Search
+ *     `39` - No Squad Search Results
+ *     `41` - Cancel Find
+ *     `42` - UNKNOWN
+ *     `43` - UNKNOWN
*   `Boolean`
*     `28` - Auto-approve Requests for Invitation
*     `29` - UNKNOWN
*     `30` - UNKNOWN
- *     `31` - Location Follows Squad Lead
+ *     `31` - Location Follows Squad Leader
*   `Int`
*     `10` - Select this Role for Yourself
*     `11` - UNKNOWN
@@ -33,49 +382,30 @@ import shapeless.{::, HNil} *   `Long`
*     `13` - UNKNOWN
*     `14` - UNKNOWN
- *     `15` - UNKNOWN
+ *     `15` - Select this Role for Yourself
*     `37` - UNKNOWN
*   `String`
- *     `7 ` - UNKNOWN
+ *     `7 ` - List Squad Favorite
*     `19` - (Squad leader) Change Squad Purpose
*   `Int :: Long`
*     `12` - UNKNOWN
*     `25` - (Squad leader) Change Squad Member Requirements - Weapons
- *     `38` - UNKNOWN
+ *     `38` - Assign Squad Member To Role
*   `Int :: String`
*     `23` - (Squad leader) Change Squad Member Requirements - Role
*     `24` - (Squad leader) Change Squad Member Requirements - Detailed Orders
*   `Long :: Long`
*     `36` - UNKNOWN
*   `String :: Long :: Int :: Int`
- *     `34` - Search for Squads with a Particular Role
- *
- * Exploration:
- * Some notes regarding the full list of action codes follows after this packet. - * Asides from codes whose behaviors are unknown, some codes also have unknown data format. - * No information for codes 1, 5, 9, 27, or 35 has been found yet. + *     `34` - Search for Squads with a Particular Role + * @param squad_guid the unique identifier of the squad, if non-zero + * @param line the original listing line number, if applicable * @param action the purpose of this packet; * also decides the content of the parameter fields - * @param unk1 na - * @param unk2 na - * @param string_opt the optional `String` parameter - * @param int1_opt the first optional `Int` parameter; - * will not necessarily conform to a single bit length - * @param int2_opt the second optional `Int` parameter - * @param long1_opt the first optional `Long` parameter; - * will not necessarily conform to a single bit length - * @param long2_opt the second optional `Long` parameter - * @param bool_opt the optional `Boolean` parameter */ -final case class SquadDefinitionActionMessage(action : Int, - unk1 : Int, - unk2 : Int, - string_opt : Option[String], - int1_opt : Option[Int], - int2_opt : Option[Int], - long1_opt : Option[Long], - long2_opt : Option[Long], - bool_opt : Option[Boolean]) +final case class SquadDefinitionActionMessage(squad_guid : PlanetSideGUID, + line : Int, + action : SquadAction) extends PlanetSideGamePacket { type Packet = SquadDefinitionActionMessage def opcode = GamePacketOpcode.SquadDefinitionActionMessage @@ -84,309 +414,78 @@ final case class SquadDefinitionActionMessage(action : Int, object SquadDefinitionActionMessage extends Marshallable[SquadDefinitionActionMessage] { /** - * Common pattern for the parameters, with enough fields to support all possible outputs. - * All fields are `Option`al purposefully. + * Use the action code to transform between + * the specific action object and its field data + * and the stream of bits of the original packet. + * @param code the action behavior code + * @return the `SquadAction` `Codec` to use for the given `code` */ - private type allPattern = Option[String] :: Option[Int] :: Option[Int] :: Option[Long] :: Option[Long] :: Option[Boolean] :: HNil - - /** - * `Codec` for reading nothing from the remainder of the stream data. - * @return a filled-out `allPattern` if successful - */ - def noneCodec : Codec[allPattern] = ignore(0).xmap[allPattern] ( - { - case () => - None :: None :: None :: None :: None :: None :: HNil - }, - { - case _ :: _ :: _ :: _ :: _ :: _ :: HNil => - () - } - ) - - /** - * `Codec` for reading a single `Boolean` from remaining stream data. - * @return a filled-out `allPattern` if successful - */ - def boolCodec : Codec[allPattern] = bool.hlist.exmap[allPattern] ( - { - case n :: HNil => - Attempt.successful(None :: None :: None :: None :: None :: Some(n) :: HNil) - }, - { - case _ :: _ :: _ :: _ :: _ :: None :: HNil => - Attempt.failure(Err("expected a boolean value but found nothing")) - case _ :: _ :: _ :: _ :: _ :: Some(n) :: HNil => - Attempt.successful(n :: HNil) - } - ) - - /** - * `Codec` for reading a single `Int` from remaining stream data. - * Multiple bit lengths can be processed from this reading. - * @param icodec the `Codec[Int]` read by this method - * @return a filled-out `allPattern` if successful - */ - def intCodec(icodec : Codec[Int]) : Codec[allPattern] = icodec.hlist.exmap[allPattern] ( - { - case n :: HNil => - Attempt.successful(None :: Some(n) :: None :: None :: None :: None :: HNil) - }, - { - case _ :: None :: _ :: _ :: _ :: _ :: HNil => - Attempt.failure(Err("expected an integer value but found nothing")) - case _ :: Some(n) :: _ :: _ :: _ :: _ :: HNil => - Attempt.successful(n :: HNil) - } - ) - - /** - * `Codec` for reading a single `Long` from remaining stream data. - * @return a filled-out `allPattern` if successful - */ - def longCodec : Codec[allPattern] = uint32L.hlist.exmap[allPattern] ( - { - case n :: HNil => - Attempt.successful(None :: None :: None :: Some(n) :: None :: None :: HNil) - }, - { - case _ :: _ :: _ :: None :: _ :: _ :: HNil => - Attempt.failure(Err("expected a long value but found nothing")) - case _ :: _ :: _ :: Some(n) :: _ :: _ :: HNil => - Attempt.successful(n :: HNil) - } - ) - - /** - * `Codec` for reading a `String` from remaining stream data. - * All `String`s processed by this reading are wide character and are padded by six. - * @return a filled-out `allPattern` if successful - */ - def stringCodec : Codec[allPattern] = PacketHelpers.encodedWideStringAligned(6).hlist.exmap[allPattern] ( - { - case a :: HNil => - Attempt.successful(Some(a) :: None :: None :: None :: None :: None :: HNil) - }, - { - case None:: _ :: _ :: _ :: _ :: _ :: HNil => - Attempt.failure(Err("expected a string value but found nothing")) - case Some(a) :: _ :: _ :: _ :: _ :: _ :: HNil => - Attempt.successful(a :: HNil) - } - ) - - /** - * `Codec` for reading an `Int` followed by a `Long` from remaining stream data. - * Multiple bit lengths can be processed for the `Long1` value from this reading. - * @param lcodec the `Codec[Long]` read by this method - * @return a filled-out `allPattern` if successful - */ - def intLongCodec(lcodec : Codec[Long]) : Codec[allPattern] = ( - uint4L :: - lcodec - ).exmap[allPattern] ( - { - case a :: b :: HNil => - Attempt.successful(None :: Some(a) :: None :: Some(b) :: None :: None :: HNil) - }, - { - case _ :: None :: _ :: _ :: _ :: _ :: HNil => - Attempt.failure(Err("expected a integer value but found nothing")) - case _ :: _ :: _ :: None :: _ :: _ :: HNil => - Attempt.failure(Err("expected a long value but found nothing")) - case _ :: Some(a) :: _ :: Some(b) :: _ :: _ :: HNil => - Attempt.successful(a :: b :: HNil) - } - ) - - /** - * `Codec` for reading an `Int` followed by a `String` from remaining stream data. - * All `String`s processed by this reading are wide character and are padded by two. - * @return a filled-out `allPattern` if successful - */ - def intStringCodec : Codec[allPattern] = ( - uint4L :: - PacketHelpers.encodedWideStringAligned(2) - ).exmap[allPattern] ( - { - case a :: b :: HNil => - Attempt.successful(Some(b) :: Some(a) :: None :: None :: None :: None :: HNil) - }, - { - case None:: _ :: _ :: _ :: _ :: _ :: HNil => - Attempt.failure(Err("expected a string value but found nothing")) - case _ :: None :: _ :: _ :: _ :: _ :: HNil => - Attempt.failure(Err("expected an integer value but found nothing")) - case Some(b) :: Some(a) :: _ :: _ :: _ :: _ :: HNil => - Attempt.successful(a :: b :: HNil) - } - ) - - /** - * `Codec` for reading two `Long`s from remaining stream data. - * @return a filled-out `allPattern` if successful - */ - def longLongCodec : Codec[allPattern] = ( - ulongL(46) :: - uint32L - ).exmap[allPattern] ( - { - case a :: b :: HNil => - Attempt.successful(None :: None :: None :: Some(a) :: Some(b) :: None :: HNil) - }, - { - case (_ :: _ :: _ :: None :: _ :: _ :: HNil) | (_ :: _ :: _ :: _ :: None :: _ :: HNil) => - Attempt.failure(Err("expected two long values but found one")) - case _ :: _ :: _ :: Some(a) :: Some(b) :: _ :: HNil => - Attempt.successful(a :: b :: HNil) - } - ) - - /** - * `Codec` for reading a `String`, a `Long`, and two `Int`s from remaining stream data. - * All `String`s processed by this reading are wide character and are padded by six. - * @return a filled-out `allPattern` if successful - */ - def complexCodec : Codec[allPattern] = ( - PacketHelpers.encodedWideStringAligned(6) :: - ulongL(46) :: - uint16L :: - uintL(3) - ).exmap[allPattern] ( - { - case a :: b :: c :: d :: HNil => - Attempt.successful(Some(a) :: Some(c) :: Some(d) :: Some(b) :: None :: None :: HNil) - }, - { - case None:: _ :: _ :: _ :: _ :: _ :: HNil => - Attempt.failure(Err("expected a string value but found nothing")) - case _ :: _ :: _ :: None :: _ :: _ :: HNil => - Attempt.failure(Err("expected a long value but found nothing")) - case (_ :: None :: _ :: _ :: _ :: _ :: HNil) | (_ :: _ :: None :: _ :: _ :: _ :: HNil) => - Attempt.failure(Err("expected two integer values but found one")) - case Some(a) :: Some(c) :: Some(d) :: Some(b) :: _ :: _ :: HNil => - Attempt.successful(a :: b :: c :: d :: HNil) - } - ) - - import scala.annotation.switch - - /** - * Select the `Codec` to translate bit data in this packet with an `allPattern` format. - * @param action the purpose of this packet; - * also decides the content of the parameter fields - * @return an `allPattern` `Codec` that parses the appropriate data - */ - def selectCodec(action : Int) : Codec[allPattern] = (action : @switch) match { - case 3 | 8 | 26 | 35 | 41 => //TODO double check these - noneCodec - - case 28 | 29 | 30 | 31 => - boolCodec - - case 33 => - intCodec(uintL(3)) - case 10 | 11 | 21 | 22 | 40 => - intCodec(uint4L) - case 20 => - intCodec(uint16L) - - case 13 | 14 | 15 | 37 => - longCodec - - case 7 | 19 => - stringCodec - - case 12 | 38 => - intLongCodec(uint32L) - case 25 => - intLongCodec(ulongL(46)) - - case 23 | 24 => - intStringCodec - - case 36 => - longLongCodec - - case 34 => - complexCodec - - case _ => - //TODO for debugging purposes only; normal failure condition below - bits.hlist.exmap[allPattern] ( - { - case x :: HNil => - org.log4s.getLogger.warn(s"can not match a codec pattern for decoding $action") - Attempt.successful(Some(x.toString) :: None :: None :: None :: None :: None :: HNil) - }, - { - case Some(x) :: None :: None :: None :: None :: None :: HNil => - org.log4s.getLogger.warn(s"can not match a codec pattern for encoding $action") - Attempt.successful(scodec.bits.BitVector.fromValidBin(x) :: HNil) - } - ) -// ignore(0).exmap[allPattern] ( -// { -// case () => -// Attempt.failure(Err(s"can not match a codec pattern for decoding $action")) -// }, -// { -// case _ :: _ :: _ :: _ :: _ :: _ :: HNil => -// Attempt.failure(Err(s"can not match a codec pattern for encoding $action")) -// } -// ) + def selectFromActionCode(code : Int) : Codec[SquadAction] = { + import SquadAction.Codecs._ + import scala.annotation.switch + ((code : @switch) match { + case 0 => displaySquadCodec + case 1 => squadMemberInitializationIssueCodec + case 2 => unknownCodec(action = 2) + case 3 => saveSquadFavoriteCodec + case 4 => loadSquadFavoriteCodec + case 5 => deleteSquadFavoriteCodec + case 6 => unknownCodec(action = 6) + case 7 => listSquadFavoriteCodec + case 8 => requestListSquadCodec + case 9 => stopListSquadCodec + case 10 => selectRoleForYourselfCodec + case 11 => unknownCodec(action = 11) + case 12 => unknownCodec(action = 12) + case 13 => unknownCodec(action = 13) + case 14 => unknownCodec(action = 14) + case 15 => cancelSelectRoleForYourselfCodec + case 16 => associateWithSquadCodec + case 17 => setListSquadCodec + case 18 => unknownCodec(action = 18) + case 19 => changeSquadPurposeCodec + case 20 => changeSquadZoneCodec + case 21 => closeSquadMemberPositionCodec + case 22 => addSquadMemberPositionCodec + case 23 => changeSquadMemberRequirementsRoleCodec + case 24 => changeSquadMemberRequirementsDetailedOrdersCodec + case 25 => changeSquadMemberRequirementsCertificationsCodec + case 26 => resetAllCodec + //case 27 => ? + case 28 => autoApproveInvitationRequestsCodec + case 29 => unknownCodec(action = 29) + case 30 => unknownCodec(action = 30) + case 31 => locationFollowsSquadLeadCodec + case 32 => unknownCodec(action = 32) + case 33 => unknownCodec(action = 33) + case 34 => searchForSquadsWithParticularRoleCodec + case 35 => cancelSquadSearchCodec + case 36 => unknownCodec(action = 36) + case 37 => unknownCodec(action = 37) + case 38 => assignSquadMemberToRoleCodec + case 39 => noSquadSearchResultsCodec + case 40 => findLfsSoldiersForRoleCodec + case 41 => cancelFindCodec + case 42 => unknownCodec(action = 42) + case 43 => unknownCodec(action = 43) + case _ => failureCodec(code) + }).asInstanceOf[Codec[SquadAction]] } implicit val codec : Codec[SquadDefinitionActionMessage] = ( - ("action" | uintL(6)) >>:~ { action => - ("unk1" | uint16L) :: - ("unk2" | uint4L) :: - selectCodec(action) + uintL(6) >>:~ { code => + ("squad_guid" | PlanetSideGUID.codec) :: + ("line" | uint4L) :: + ("action" | selectFromActionCode(code)) } - ).as[SquadDefinitionActionMessage] + ).xmap[SquadDefinitionActionMessage] ( + { + case _ :: guid :: line :: action :: HNil => + SquadDefinitionActionMessage(guid, line, action) + }, + { + case SquadDefinitionActionMessage(guid, line, action) => + action.code :: guid :: line :: action :: HNil + } + ) } - -/* -("change" specifically indicates the perspective is from the SL; "update" indicates squad members other than the oen who made the change -("[#]" indicates the mode is detected but not properly parsed; the length of the combined fields may follow - -[0] - clicking on a squad listed in the "Find Squad" tab / cancel squad search (6 bits/pad?) -[2] - ? (6 bits/pad?) -[3] - save squad favorite (6 bits/pad?) -[4] - load a squad definition favorite (6 bits/pad?) -[6] - ? (6 bits/pad?) -7 - ? -[8] - list squad (6 bits/pad?) -10 - select this role for yourself -11 - ? -12 - ? -13 - ? -14 - ? -15 - ? -[16] - ? (6 bits/pad?) -[17] - ? (6 bits/pad?) -[18] - ? (6 bits/pad?) -19 - change purpose -20 - change zone -21 - change/close squad member position -22 - change/add squad member position -23 - change squad member req role -24 - change squad member req detailed orders -25 - change squad member req weapons -[26] - reset all (6 bits/pad?) -28 - auto-approve requests for invitation -29 - -30 - -31 - location follows squad lead -[32] - ? (6 bits/pad?) -33 - -34 - search for squads with a particular role -36 - -37 - -38 - -[39] - ? (?) -40 - find LFS soldiers that meet the requirements for this role -[41] - cancel search for LFS soldiers (6 bits) -[42] - ? (6 bits/pad?) -[43] - ? (6 bits/pad?) -*/ diff --git a/common/src/main/scala/net/psforever/packet/game/SquadDetailDefinitionUpdateMessage.scala b/common/src/main/scala/net/psforever/packet/game/SquadDetailDefinitionUpdateMessage.scala new file mode 100644 index 000000000..7c60d0483 --- /dev/null +++ b/common/src/main/scala/net/psforever/packet/game/SquadDetailDefinitionUpdateMessage.scala @@ -0,0 +1,1416 @@ +// Copyright (c) 2019 PSForever +package net.psforever.packet.game + +import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket} +import net.psforever.types.CertificationType +import scodec.{Attempt, Codec, Err} +import scodec.codecs._ +import shapeless.HNil + +import scala.annotation.tailrec + +/** + * A container that should be used to keep track of the current length of a stream of bits. + * @param init the starting pad value; + * defaults to 0 + */ +class StreamLengthToken(init : Int = 0) { + private var bitLength : Int = init + + def Length : Int = bitLength + + def Length_=(toLength : Int) : StreamLengthToken = { + bitLength = toLength + this + } + + def Add(more : Int) : StreamLengthToken = { + bitLength += more + this + } +} + +/** + * Information regarding a squad's position as a series of common fields. + * When parsed in an itemized way, only the important fields are represented. + * When parsed in a continuous manner, all of the fields are populated. + * All fields are optional for that reason. + * @param is_closed availability, whether the position can be occupied by a player; + * an unavailable position is referenced as "Closed" and no other position detail is displayed; + * an available unoccupied position is "Available" + * @param role the title of the position + * @param detailed_orders the suggested responsibilities of the position + * @param requirements the actual responsibilities of the position + * @param char_id the unique character identification number for the player that is occupying this position + * @param name the name of the player who is occupying this position + */ +final case class SquadPositionDetail(is_closed : Option[Boolean], + role : Option[String], + detailed_orders : Option[String], + requirements : Option[Set[CertificationType.Value]], + char_id : Option[Long], + name : Option[String]) { + /** + * Combine two `SquadPositionDetail` objects, with priority given to `this` one. + * Most fields that are not empty are assigned. + * Even if the current object reports the squad position being open - `is_closed = Some(false)` - + * just one instance of the squad position being closed overwrites all future updates. + * @param info the object being combined + * @return the combined `SquadDetail` object + */ + def And(info : SquadPositionDetail) : SquadPositionDetail = { + SquadPositionDetail( + is_closed match { + case Some(false) | None => + info.is_closed.orElse(is_closed) + case _ => + Some(true) + }, + role.orElse(info.role), + detailed_orders.orElse(info.detailed_orders), + requirements.orElse(info.requirements), + char_id.orElse(info.char_id), + name.orElse(info.name) + ) + } + + //methods intended to combine the fields of itself and another object + def Open : SquadPositionDetail = + this And SquadPositionDetail(Some(false), None, None, None, None, None) + def Close : SquadPositionDetail = + this And SquadPositionDetail(Some(true), None, None, None, None, None) + def Role(role : String) : SquadPositionDetail = + this And SquadPositionDetail(None, Some(role), None, None, None, None) + def DetailedOrders(orders : String) : SquadPositionDetail = + this And SquadPositionDetail(None, None, Some(orders), None, None, None) + def Requirements(req : Set[CertificationType.Value]) : SquadPositionDetail = + this And SquadPositionDetail(None, None, None, Some(req), None, None) + def CharId(char_id : Long) : SquadPositionDetail = + this And SquadPositionDetail(None, None, None, None, Some(char_id), None) + def Name(name : String) : SquadPositionDetail = + this And SquadPositionDetail(None, None, None, None, None, Some(name)) + def Player(char_id : Long, name : String) : SquadPositionDetail = + this And SquadPositionDetail(None, None, None, None, Some(char_id), Some(name)) + + /** + * Complete the object by providing placeholder values for all fields. + * @return a `SquadPositionDetail` object with all of its field populated + */ + def Complete : SquadPositionDetail = SquadPositionDetail( + is_closed.orElse(Some(false)), + role.orElse(Some("")), + detailed_orders.orElse(Some("")), + requirements.orElse(Some(Set.empty)), + char_id.orElse(Some(0L)), + name.orElse(Some("")) + ) +} + +/** + * A container for squad position field data + * associating what would be the ordinal position of that field data in full squad data. + * @param index the index for this squad position; + * expected to be a number 0-9 or 255; + * when 255, this indicated the end of enumerated squad position data and the data for that position is absent + * @param info the squad position field data + */ +final case class SquadPositionEntry(index : Int, info : Option[SquadPositionDetail]) { + assert((index > -1 && index < 10) || index == 255, "index value is out of range 0=>n<=9 or n=255") + assert(if(index == 255) { info.isEmpty } else { true }, "index=255 indicates end of stream exclusively and field data should be blank") +} + +/** + * Information regarding a squad's position as a series of common fields. + * When parsed in an itemized way, only the important fields are represented. + * When parsed in a continuous manner, all of the fields are populated. + * All fields are optional for that reason.
+ *
+ * The squad leader does not necessarily have to be a person from the `member_info` list. + * @param unk1 na; + * must be non-zero when parsed in a FullSquad pattern + * @param unk2 na; + * not associated with any fields during itemized parsing + * @param leader_char_id he unique character identification number for the squad leader + * @param unk3 na + * @param leader_name the name of the player who is the squad leader + * @param task the suggested responsibilities or mission statement of the squad + * @param zone_id the suggested area of engagement for this squad's activities; + * can also indicate the zone of the squad leader + * @param unk7 na + * @param member_info a list of squad position data + */ +final case class SquadDetail(unk1 : Option[Int], + unk2 : Option[Int], + leader_char_id : Option[Long], + unk3 : Option[Long], + leader_name : Option[String], + task : Option[String], + zone_id : Option[PlanetSideZoneID], + unk7 : Option[Int], + member_info : Option[List[SquadPositionEntry]]) { + /** + * Combine two `SquadDetail` objects, with priority given to `this` one. + * Most fields that are not empty are assigned. + * @param info the object being combined + * @return the combined `SquadDetail` object + */ + def And(info : SquadDetail) : SquadDetail = { + SquadDetail( + unk1.orElse(info.unk1), + unk2.orElse(info.unk2), + leader_char_id.orElse(info.leader_char_id), + unk3.orElse(info.unk3), + leader_name.orElse(info.leader_name), + task.orElse(info.task), + zone_id.orElse(info.zone_id), + unk7.orElse(info.unk7), + { + (member_info, info.member_info) match { + case (Some(info1), Some(info2)) => + //combine the first list with the elements of the second list whose indices not found in the first list + val indices = info1.map { _.index } + Some(info1 ++ (for { + position <- info2 + if !indices.contains(position.index) + } yield position).sortBy(_.index)) + case (Some(info1), None) => + Some(info1) + case (None, _) => + info.member_info + } + } + ) + } + + //methods intended to combine the fields of itself and another object + def Field1(value : Int) : SquadDetail = + this And SquadDetail(Some(value), None, None, None, None, None, None, None, None) + def LeaderCharId(char_id : Long) : SquadDetail = + this And SquadDetail(None, None, Some(char_id), None, None, None, None, None, None) + def Field3(value : Long) : SquadDetail = + this And SquadDetail(None, None, None, Some(value), None, None, None, None, None) + def LeaderName(name : String) : SquadDetail = + this And SquadDetail(None, None, None, None, Some(name), None, None, None, None) + def Leader(char_id : Long, name : String) : SquadDetail = + this And SquadDetail(None, None, Some(char_id), None, Some(name), None, None, None, None) + def Task(task : String) : SquadDetail = + this And SquadDetail(None, None, None, None, None, Some(task), None, None, None) + def ZoneId(zone : PlanetSideZoneID) : SquadDetail = + this And SquadDetail(None, None, None, None, None, None, Some(zone), None, None) + def Field7(value : Int) : SquadDetail = + this And SquadDetail(None, None, None, None, None, None, None, Some(value), None) + def Members(list : List[SquadPositionEntry]) : SquadDetail = + this And SquadDetail(None, None, None, None, None, None, None, None, Some(list)) + + /** + * Complete the object by providing placeholder values for all fields. + * The `member_info` field requires additional allocation. + * @return a `SquadDetail` object with all of its field populated + */ + def Complete : SquadDetail = SquadDetail( + unk1.orElse(Some(1)), + unk2.orElse(Some(0)), + leader_char_id.orElse(Some(0L)), + unk3.orElse(Some(0L)), + leader_name.orElse(Some("")), + task.orElse(Some("")), + zone_id.orElse(Some(PlanetSideZoneID(0))), + unk7.orElse(Some(4983296)), //FullSquad value + { + val complete = SquadPositionDetail().Complete + Some(member_info match { + case Some(info) => + //create one list that ensures all existing positions are "complete" then add a list of the missing indices + val (indices, fields) = info.collect { + case SquadPositionEntry(a, Some(b)) => (a, SquadPositionEntry(a, b.Complete)) + case out @ SquadPositionEntry(a, None) => (a, out) + }.unzip + ((0 to 9).toSet.diff(indices.toSet).map { SquadPositionEntry(_, complete) } ++ fields).toList.sortBy(_.index) + case None => + //original list + (0 to 9).map { i => SquadPositionEntry(i, complete) }.toList + }) + } + ) +} + +/** + * A compilation of the fields that communicate detailed information about squad structure and composition + * as a complement to the packet `ReplicationStreamMessage` and the packet `SquadDefinitionActionMessage`. + * The information communicated by the `SquadDefinitionActionMessage` packets allocates individual fields of the squad's structure + * and the `ReplicationStreamMessage` packet reports very surface-level information about the squad to other players. + * The `SquadDetailDefinitionUpdateMessage` packet serves as a realization of the field information reported by the former + * and a fully fleshed-out explanation of the information presented by the latter.
+ *
+ * Squads are generally referenced by their own non-zero globally unique identifier that is valid server-wide. + * A zero GUID squad is also accessible for information related to the local unpublished squad that exists on a specific client. + * Only one published squad can have its information displayed at a time. + * While imperfect squad information can be shown, two major formats for the data in this packet are common. + * The first format lists all of the squad's fields and data and is used as an initialization of the squad locally. + * This format is always used the first time information about the squad is communicated to the client. + * The second format lists specific portions of the squad's fields and data and is used primarily for simple updating purposes. + * @param guid the globally unique identifier of the squad + * @param detail information regarding the squad + */ +final case class SquadDetailDefinitionUpdateMessage(guid : PlanetSideGUID, + detail : SquadDetail) + extends PlanetSideGamePacket { + type Packet = SquadDetailDefinitionUpdateMessage + def opcode = GamePacketOpcode.SquadDetailDefinitionUpdateMessage + def encode = SquadDetailDefinitionUpdateMessage.encode(this) +} + +object SquadPositionDetail { + /** + * A featureless squad position. + * References the default overloaded constructor. + */ + final val Blank : SquadPositionDetail = SquadPositionDetail() + /** + * An unavailable squad position. + */ + final val Closed : SquadPositionDetail = SquadPositionDetail(is_closed = Some(true), None, None, None, None, None) + /** + * An available squad position. + */ + final val Open : SquadPositionDetail = SquadPositionDetail(is_closed = Some(false), None, None, None, None, None) + + /** + * An overloaded constructor that produces a featureless squad position. + * @return a `SquadPositionDetail` object + */ + def apply() : SquadPositionDetail = SquadPositionDetail(None, None, None, None, None, None) + /** + * An overloaded constructor that produces a full squad position with a role, detailed orders, and certification requirements. + * This basically defines an available squad position that is unoccupied. + * @return a `SquadPositionDetail` object + */ + def apply(role : String, detailed_orders : String, requirements : Set[CertificationType.Value], char_id : Long, name : String) : SquadPositionDetail = SquadPositionDetail(Some(false), Some(role), Some(detailed_orders), Some(requirements), Some(char_id), Some(name)) + + object Fields { + final val Closed = 0 + final val Role = 1 + final val Orders = 2 + final val CharId = 3 + final val Name = 4 + final val Requirements = 5 + } +} + +object SquadPositionEntry { + /** + * An overloaded constructor. + * @return a `SquadPositionEntry` object + */ + def apply(index : Int, detail : SquadPositionDetail) : SquadPositionEntry = SquadPositionEntry(index, Some(detail)) +} + +object SquadDetail { + /** + * A featureless squad. + * References the default overloaded constructor. + */ + final val Blank = SquadDetail() + + /** + * An overloaded constructor that produces a featureless squad. + * @return a `SquadDetail` object + */ + def apply() : SquadDetail = SquadDetail(None, None, None, None, None, None, None, None, None) + /** + * An overloaded constructor that produces a complete squad with all fields populated. + * @return a `SquadDetail` object + */ + def apply(unk1 : Int, unk2 : Int, leader_char_id : Long, unk3 : Long, leader_name : String, task : String, zone_id : PlanetSideZoneID, unk7 : Int, member_info : List[SquadPositionEntry]) : SquadDetail = { + SquadDetail(Some(unk1), Some(unk2), Some(leader_char_id), Some(unk3), Some(leader_name), Some(task), Some(zone_id), Some(unk7), Some(member_info)) + } + + //individual field overloaded constructors + def Field1(unk1 : Int) : SquadDetail = + SquadDetail(Some(unk1), None, None, None, None, None, None, None, None) + def LeaderCharId(char_id : Long) : SquadDetail = + SquadDetail(None, None, Some(char_id), None, None, None, None, None, None) + def Field3(char_id : Option[Long], unk3 : Long) : SquadDetail = + SquadDetail(None, None, None, Some(unk3), None, None, None, None, None) + def LeaderName(name : String) : SquadDetail = + SquadDetail(None, None, None, None, Some(name), None, None, None, None) + def Leader(char_id : Long, name : String) : SquadDetail = + SquadDetail(None, None, Some(char_id), None, Some(name), None, None, None, None) + def Task(task : String) : SquadDetail = + SquadDetail(None, None, None, None, None, Some(task), None, None, None) + def ZoneId(zone : PlanetSideZoneID) : SquadDetail = + SquadDetail(None, None, None, None, None, None, Some(zone), None, None) + def Field7(unk7 : Int) : SquadDetail = + SquadDetail(None, None, None, None, None, None, None, Some(unk7), None) + def Members(list : List[SquadPositionEntry]) : SquadDetail = + SquadDetail(None, None, None, None, None, None, None, None, Some(list)) + + object Fields { + final val Field1 = 1 + final val CharId = 2 + final val Field3 = 3 + final val Leader = 4 + final val Task = 5 + final val ZoneId = 6 + final val Field7 = 7 + final val Members = 8 + } +} + +object SquadDetailDefinitionUpdateMessage extends Marshallable[SquadDetailDefinitionUpdateMessage] { + /** + * The patterns necessary to read uncoded squad data. + * All squad fields and all squad position fields are parsed. + */ + object FullSquad { + /** + * The first squad position entry has its first string (`role`) field padded by a constant amount. + */ + private val first_position_codec : Codec[SquadPositionDetail] = basePositionCodec(bitsOverByteLength = 1, DefaultRequirements) + /** + * All squad position entries asides from the first have unpadded strings. + * The very first entry aligns the remainder of the string fields along byte boundaries. + */ + private val position_codec : Codec[SquadPositionDetail] = basePositionCodec(bitsOverByteLength = 0, DefaultRequirements) + + /** + * Internal class for linked list operations. + * @param info details regarding the squad position + * @param next if there is a "next" squad position + */ + private final case class LinkedFields(info: SquadPositionDetail, next: Option[LinkedFields]) + + /** + * Parse each squad position field in the bitstream after the first one. + * @return a pattern outlining sequential squad positions + */ + private def subsequent_member_codec : Codec[LinkedFields] = { + import shapeless.:: + ( + //disruptive coupling action (e.g., flatPrepend) is necessary for recursive Codec + ("member" | position_codec) >>:~ { _ => + optional(bool, "next" | subsequent_member_codec).hlist + } + ).xmap[LinkedFields] ( + { + case a :: b :: HNil => + LinkedFields(a, b) + }, + { + case LinkedFields(a, b) => + a :: b :: HNil + } + ) + } + + /** + * Parse the first squad position field in the bitstream. + * @return a pattern outlining sequential squad positions + */ + private def initial_member_codec : Codec[LinkedFields] = { + import shapeless.:: + ( + ("member" | first_position_codec) :: + optional(bool, "next" | subsequent_member_codec) + ).xmap[LinkedFields] ( + { + case a :: b :: HNil => + LinkedFields(a, b) + }, + { + case LinkedFields(a, b) => + a :: b :: HNil + } + ) + } + + /** + * Transform a linked list of squad position data into a normal `List`. + * @param list the current section of the original linked list + * @param out the accumulative traditional `List` + * @return the final `List` output + */ + @tailrec + private def unlinkFields(list : LinkedFields, out : List[SquadPositionDetail] = Nil) : List[SquadPositionDetail] = { + list.next match { + case None => + out :+ list.info + case Some(next) => + unlinkFields(next, out :+ list.info) + } + } + + /** + * Transform a normal `List` of squad position data into a linked list. + * The original list becomes reversed in the process. + * @param list the original traditional `List` + * @return the final linked list output + */ + private def linkFields(list : List[SquadPositionDetail]) : LinkedFields = { + list match { + case Nil => + throw new Exception("") + case x :: Nil => + LinkedFields(x, None) + case x :: xs => + linkFields(xs, LinkedFields(x, None)) + } + } + + /** + * Transform a normal `List` of squad position data into a linked list. + * The original list becomes reversed in the process. + * @param list the current subsection of the original traditional `List` + * @param out the accumulative linked list + * @return the final linked list output + */ + @tailrec + private def linkFields(list : List[SquadPositionDetail], out : LinkedFields) : LinkedFields = { + list match { + case Nil => + out + case x :: Nil => + LinkedFields(x, Some(out)) + case x :: xs => + linkFields(xs, LinkedFields(x, Some(out))) + } + } + + /** + * Entry point. + */ + val codec : Codec[SquadDetail] = { + import shapeless.:: + ( + ("unk1" | uint8) :: + ("unk2" | uint24) :: //unknown, but can be 0'd + ("leader_char_id" | uint32L) :: + ("unk3" | uint32L) :: //variable fields, but can be 0'd + ("leader" | PacketHelpers.encodedWideStringAligned(7)) :: + ("task" | PacketHelpers.encodedWideString) :: + ("zone_id" | PlanetSideZoneID.codec) :: + ("unk7" | uint(23)) :: //during full squad mode, constant = 4983296 + optional(bool, "member_info" | initial_member_codec) + ).exmap[SquadDetail] ( + { + case u1 :: u2 :: char_id :: u3 :: leader :: task :: zone :: unk7 :: Some(member_list) :: HNil => + Attempt.Successful( + SquadDetail(Some(u1), Some(u2), Some(char_id), Some(u3), Some(leader), Some(task), Some(zone), Some(unk7), + Some(unlinkFields(member_list).zipWithIndex.map { case (entry, index) => SquadPositionEntry(index, Some(entry)) }) + ) + ) + case data => + Attempt.failure(Err(s"can not get squad detail definition from data $data")) + }, + { + case SquadDetail(Some(u1), Some(u2), Some(char_id), Some(u3), Some(leader), Some(task), Some(zone), Some(unk7), Some(member_list)) => + Attempt.Successful( + math.max(u1, 1) :: u2 :: char_id :: u3 :: leader :: task :: zone :: unk7 :: + Some(linkFields(member_list.collect { case SquadPositionEntry(_, Some(entry)) => entry }.reverse)) :: + HNil + ) + } + ) + } + } + + /** + * The patterns necessary to read coded squad data fields. + * Any number of squad fields can be parsed, + * but the number is always counted and the fields are always preceded by a unique action code. + * Only important fields are listed as if to update them; + * unlisted fields indicate fields that do not get updated from their current values. + */ + object ItemizedSquad { + /** + * A pattern for data related to "field1." + */ + private val field1Codec : Codec[SquadDetail] = uint16L.exmap[SquadDetail] ( + unk1 => Attempt.successful(SquadDetail(Some(unk1), None, None, None, None, None, None, None, None)), + { + case SquadDetail(Some(unk1), _, _, _, _, _, _, _, _) => + Attempt.successful(unk1) + case _ => + Attempt.failure(Err("failed to encode squad data for unknown field #1")) + } + ) + /** + * A pattern for data related to the squad leader's `char_id` field. + */ + private val leaderCharIdCodec : Codec[SquadDetail] = uint32L.exmap[SquadDetail] ( + char_id => Attempt.successful(SquadDetail(None, None, Some(char_id), None, None, None, None, None, None)), + { + case SquadDetail(_, _, Some(char_id), _, _, _, _, _, _) => + Attempt.successful(char_id) + case _ => + Attempt.failure(Err("failed to encode squad data for leader id")) + } + ) + /** + * A pattern for data related to "field3." + */ + private val field3Codec : Codec[SquadDetail] = uint32L.exmap[SquadDetail] ( + unk3 => Attempt.successful(SquadDetail(None, None, None, Some(unk3), None, None, None, None, None)), + { + case SquadDetail(_, _, _, Some(unk3), _, _, _, _, _) => + Attempt.successful(unk3) + case _ => + Attempt.failure(Err("failed to encode squad data for unknown field #3")) + } + ) + /** + * A pattern for data related to the squad leader's `name` field. + * @param bitsOverByte a token maintaining stream misalignment for purposes of calculating string padding + */ + private def leaderNameCodec(bitsOverByte : StreamLengthToken) : Codec[SquadDetail] = paddedStringMetaCodec(bitsOverByte.Length).exmap[SquadDetail] ( + name => Attempt.successful(SquadDetail(None, None, None, None, Some(name), None, None, None, None)), + { + case SquadDetail(_, _, _, _, Some(name), _, _, _, _) => + Attempt.successful(name) + case _ => + Attempt.failure(Err("failed to encode squad data for leader name")) + } + ) + /** + * A pattern for data related to the squad's `task` field, also often described as the squad description. + * @param bitsOverByte a token maintaining stream misalignment for purposes of calculating string padding + */ + private def taskCodec(bitsOverByte : StreamLengthToken) : Codec[SquadDetail] = paddedStringMetaCodec(bitsOverByte.Length).exmap[SquadDetail] ( + task => Attempt.successful(SquadDetail(None, None, None, None, None, Some(task), None, None, None)), + { + case SquadDetail(_, _, _, _, _, Some(task), _, _, _) => + Attempt.successful(task) + case _ => + Attempt.failure(Err("failed to encode squad data for task")) + } + ) + /** + * A pattern for data related to the squad leader's `zone_id` field. + * @see `PlanetSideZoneID.codec` + */ + private val zoneCodec : Codec[SquadDetail] = PlanetSideZoneID.codec.exmap[SquadDetail] ( + zone_id => Attempt.successful(SquadDetail(None, None, None, None, None, None, Some(zone_id), None, None)), + { + case SquadDetail(_, _, _, _, _, _, Some(zone_id), _, _) => + Attempt.successful(zone_id) + case _ => + Attempt.failure(Err("failed to encode squad data for zone id")) + } + ) + /** + * A pattern for data related to "field7." + */ + private val field7Codec : Codec[SquadDetail] = { + uint4.exmap[SquadDetail] ( + unk7 => Attempt.successful(SquadDetail(None, None, None, None, None, None, None, Some(unk7), None)), + { + case SquadDetail(_, _, _, _, _, _, _, Some(unk7), _) => + Attempt.successful(unk7) + case _ => + Attempt.failure(Err("failed to encode squad data for unknown field #7")) + } + ) + } + /** + * A pattern for data related to the squad's position entry fields. + * The actual parsing of the data for the positions diverges + * into either an itemized parsing pattern + * or a fully populated parsing pattern. + * @param bitsOverByte a token maintaining stream misalignment for purposes of calculating string padding + */ + private def membersCodec(bitsOverByte : StreamLengthToken) : Codec[SquadDetail] = { + import shapeless.:: + ( + bool >>:~ { flag => + conditional(flag, { + bitsOverByte.Add(4) + uint(3) :: vector(ItemizedPositions.codec(bitsOverByte)) + }) :: + conditional(!flag, { + bitsOverByte.Add(3) + uint2 :: FullyPopulatedPositions.codec(bitsOverByte) + }) + } + ).exmap[SquadDetail] ( + { + case true :: Some(_ :: member_list :: HNil) :: _ :: HNil => + Attempt.successful(SquadDetail(None, None, None, None, None, None, None, None, Some(ignoreTerminatingEntry(member_list.toList)))) + case false :: None :: Some(_ :: member_list :: HNil) :: HNil => + Attempt.successful(SquadDetail(None, None, None, None, None, None, None, None, Some(ignoreTerminatingEntry(member_list.toList)))) + }, + { + case SquadDetail(_, _, _, _, _, _, _, _, Some(member_list)) => + if(member_list + .collect { case position if position.info.nonEmpty => + val info = position.info.get + List(info.is_closed, info.role, info.detailed_orders, info.requirements, info.char_id, info.name) + } + .flatten + .count(_.isEmpty) == 0) { + Attempt.successful(false :: None :: Some(2 :: ensureTerminatingEntry(member_list).toVector :: HNil) :: HNil) + } + else { + Attempt.successful(true :: Some(4 :: ensureTerminatingEntry(member_list).toVector :: HNil) :: None :: HNil) + } + case _ => + Attempt.failure(Err("failed to encode squad data for members")) + } + ) + } + /** + * A failing pattern for when the coded value is not tied to a known field pattern. + * This pattern does not read or write any bit data. + * The `conditional` will always return `None` because + * its determining conditional statement is explicitly `false` + * and all cases involving explicit failure. + * @param code the unknown action code + */ + private def failureCodec(code : Int) : Codec[SquadDetail] = conditional(included = false, bool).exmap[SquadDetail] ( + _ => Attempt.failure(Err(s"decoding squad data with unhandled codec - $code")), + _ => Attempt.failure(Err(s"encoding squad data with unhandled codec - $code")) + ) + + /** + * Retrieve the field pattern by its associated action code. + * @param code the action code + * @param bitsOverByte a token maintaining stream misalignment for purposes of calculating string padding + * @return the field pattern + */ + private def selectCodedAction(code : Int, bitsOverByte : StreamLengthToken) : Codec[SquadDetail] = { + code match { + case 1 => field1Codec + case 2 => leaderCharIdCodec + case 3 => field3Codec + case 4 => leaderNameCodec(bitsOverByte) + case 5 => taskCodec(bitsOverByte) + case 6 => zoneCodec + case 7 => field7Codec + case 8 => membersCodec(bitsOverByte) + case _ => failureCodec(code) + } + } + + /** + * Advance information about the current stream length because on which pattern was previously utilized. + * @see `selectCodedAction(Int, StreamLengthToken)` + * @param code the action code, connecting to a field pattern + * @param bitsOverByte a token maintaining stream misalignment for purposes of calculating string padding + * @return a modified token maintaining stream misalignment + */ + private def modifyCodedPadValue(code : Int, bitsOverByte : StreamLengthToken) : StreamLengthToken = { + code match { + case 1 => bitsOverByte //16u = no added padding + case 2 => bitsOverByte //32u = no added padding + case 3 => bitsOverByte //32u = no added padding + case 4 => bitsOverByte.Length = 0 //byte-aligned string; padding zero'd + case 5 => bitsOverByte.Length = 0 //byte-aligned string; padding zero'd + case 6 => bitsOverByte //32u = no added padding + case 7 => bitsOverByte.Add(4) //additional 4u + case 8 => bitsOverByte.Length = 0 //end of stream + case _ => bitsOverByte.Length = Int.MinValue //wildly incorrect + } + } + + /** + * Internal class for linked list operations. + * @param code action code indicating the squad field + * @param info data for the squad field + * @param next if there is a "next" squad field + */ + private final case class LinkedFields(code : Int, info : SquadDetail, next : Option[LinkedFields]) + + /** + * Transform a linked list of individual squad field data into a combined squad data object. + * @param list the current section of the original linked list + * @return the final squad object output + */ + private def unlinkFields(list : LinkedFields) : SquadDetail = unlinkFields(Some(list)) + + /** + * Transform a linked list of individual squad field data into a combined squad data object. + * @param info the current section of the original linked list + * @param out the accumulative squad data object + * @return the final squad object output + */ + @tailrec + private def unlinkFields(info : Option[LinkedFields], out : SquadDetail = SquadDetail.Blank) : SquadDetail = { + info match { + case None => + out + case Some(sqInfo) => + unlinkFields(sqInfo.next, out And sqInfo.info) + } + } + + /** + * Transform a squad detail object whose field population may be sparse into a linked list of individual fields. + * Fields of the combined object are separated into a list of pairs + * of each of those fields's action codes and a squad detail object with only that given field populated. + * After the blank entries are eliminated, the remaining fields are transformed into a linked list. + * @param info the combined squad detail object + * @return the final linked list output + */ + private def linkFields(info : SquadDetail) : LinkedFields = { + Seq( + (8, SquadDetail(None, None, None, None, None, None, None, None, info.member_info)), + (7, SquadDetail(None, None, None, None, None, None, None, info.unk7, None)), + (6, SquadDetail(None, None, None, None, None, None, info.zone_id, None, None)), + (5, SquadDetail(None, None, None, None, None, info.task, None, None, None)), + (4, SquadDetail(None, None, None, None, info.leader_name, None, None, None, None)), + (3, SquadDetail(None, None, None, info.unk3, None, None, None, None, None)), + (2, SquadDetail(None, None, info.leader_char_id, None, None, None, None, None, None)), + (1, SquadDetail(info.unk1, None, None, None, None, None, None, None, None)) + ) //in reverse order so that the linked list is in the correct order + .filterNot { case (_, sqInfo) => sqInfo == SquadDetail.Blank} + match { + case Nil => + throw new Exception("no linked list squad fields encountered where at least one was expected") //bad end + case x :: Nil => + val (code, squadInfo) = x + LinkedFields(code, squadInfo, None) + case x :: xs => + val (code, squadInfo) = x + linkFields(xs, LinkedFields(code, squadInfo, None)) + } + } + + /** + * Transform a `List` of squad field data paired with its field action code into a linked list. + * @param list the current subsection of the original list of fields + * @param out the accumulative linked list + * @return the final linked list output + */ + @tailrec + private def linkFields(list : Seq[(Int, SquadDetail)], out : LinkedFields) : LinkedFields = { + if(list.isEmpty) { + out + } + else { + val (code, data) = list.head + linkFields(list.tail, LinkedFields(code, data, Some(out))) + } + } + + /** + * Parse each action code to determine the format of the following squad field. + * Keep parsing until all reported squad fields have been encountered. + * @param size the number of fields to be parsed + * @param bitsOverByte a token maintaining stream misalignment for purposes of calculating string padding + * @return a linked list composed of the squad fields + */ + private def chain_linked_fields(size : Int, bitsOverByte : StreamLengthToken) : Codec[LinkedFields] = { + import shapeless.:: + ( + //disruptive coupling action (e.g., flatPrepend) is necessary for recursive Codec + uint4 >>:~ { code => + selectCodedAction(code, bitsOverByte.Add(4)) :: + conditional(size - 1 > 0, chain_linked_fields(size - 1, modifyCodedPadValue(code, bitsOverByte))) + } + ).exmap[LinkedFields] ( + { + case action :: detail :: next :: HNil => + Attempt.Successful(LinkedFields(action, detail, next)) + }, + { + case LinkedFields(action, detail, next) => + Attempt.Successful(action :: detail :: next :: HNil) + } + ) + } + + /** + * Entry point. + * The stream misalignment will always be by 1 bit over the previous boundary when this is invoked. + * @param size the number of squad fields to be parsed + * @return a pattern for parsing the coded squad field data between a coded linked list and a combined squad object + */ + def codec(size : Int) : Codec[SquadDetail] = chain_linked_fields(size, new StreamLengthToken(1)).xmap[SquadDetail] ( + linkedDetail => unlinkFields(linkedDetail), + unlinkedDetail => linkFields(unlinkedDetail) + ) + } + + /** + * The patterns necessary to read coded itemized squad position data fields. + * The main squad position data has been completed and now the squad's open positions are being parsed. + * Any number of squad position fields can be parsed, + * but the number is always counted and the fields are always preceded by a unique action code. + * Only important fields are listed as if to update them; + * unlisted fields indicate fields that do not get updated from their current values. + */ + object ItemizedPositions { + /** + * A pattern for data related to the squad position's `is_closed` field. + */ + private val isClosedCodec : Codec[SquadPositionDetail] = bool.exmap[SquadPositionDetail] ( + state => Attempt.successful(SquadPositionDetail(Some(state), None, None, None, None, None)), + { + case SquadPositionDetail(Some(state), _, _, _, _, _) => + Attempt.successful(state) + case _ => + Attempt.failure(Err("failed to encode squad position data for availability")) + } + ) + /** + * A pattern for data related to the squad position's `role` field. + * @see `SquadDetailDefinitionUpdateMessage.paddedStringMetaCodec` + * @param bitsOverByte a token maintaining stream misalignment for purposes of calculating string padding + */ + private def roleCodec(bitsOverByte : StreamLengthToken) : Codec[SquadPositionDetail] = paddedStringMetaCodec(bitsOverByte.Length).exmap[SquadPositionDetail] ( + role => Attempt.successful(SquadPositionDetail(None, Some(role), None, None, None, None)), + { + case SquadPositionDetail(_, Some(role), _, _, _, _) => + Attempt.successful(role) + case _ => + Attempt.failure(Err("failed to encode squad position data for role")) + } + ) + /** + * A pattern for data related to the squad position's `detailed_orders` field. + * @see `SquadDetailDefinitionUpdateMessage.paddedStringMetaCodec` + * @param bitsOverByte a token maintaining stream misalignment for purposes of calculating string padding + */ + private def ordersCodec(bitsOverByte : StreamLengthToken) : Codec[SquadPositionDetail] = paddedStringMetaCodec(bitsOverByte.Length).exmap[SquadPositionDetail] ( + orders => Attempt.successful(SquadPositionDetail(None, None, Some(orders), None, None, None)), + { + case SquadPositionDetail(_, _, Some(orders), _, _, _) => + Attempt.successful(orders) + case _ => + Attempt.failure(Err("failed to encode squad position data for detailed orders")) + } + ) + /** + * A pattern for data related to the squad position's `requirements` field. + * @see `CertificationType.fromEncodedLong` + * @see `CertificationType.toEncodedLong` + * @see `SquadDefinitionActionMessage.ChangeSquadMemberRequirementsCertifications` + */ + private val requirementsCodec : Codec[SquadPositionDetail] = ulongL(46).exmap[SquadPositionDetail] ( + requirements => Attempt.successful(SquadPositionDetail(None, None, None, Some(CertificationType.fromEncodedLong(requirements)), None, None)), + { + case SquadPositionDetail(_, _, _, Some(requirements), _, _) => + Attempt.successful(CertificationType.toEncodedLong(requirements)) + case _ => + Attempt.failure(Err("failed to encode squad position data for certification requirements")) + } + ) + /** + * A pattern for data related to the squad position's `char_id` field, when occupied. + */ + private val charIdCodec : Codec[SquadPositionDetail] = uint32L.exmap[SquadPositionDetail] ( + char_id => Attempt.successful(SquadPositionDetail(None, None, None, None, Some(char_id), None)), + { + case SquadPositionDetail(_, _, _, _, Some(char_id), _) => + Attempt.successful(char_id) + case _ => + Attempt.failure(Err("failed to encode squad data for member id")) + } + ) + /** + * A pattern for data related to the squad position's `name` field, when occupied. + * @see `SquadDetailDefinitionUpdateMessage.paddedStringMetaCodec` + * @param bitsOverByte a token maintaining stream misalignment for purposes of calculating string padding + */ + private def nameCodec(bitsOverByte : StreamLengthToken) : Codec[SquadPositionDetail] = paddedStringMetaCodec(bitsOverByte.Length).exmap[SquadPositionDetail] ( + name => Attempt.successful(SquadPositionDetail(None, None, None, None, None, Some(name))), + { + case SquadPositionDetail(_, _, _, _, _, Some(orders)) => + Attempt.successful(orders) + case _ => + Attempt.failure(Err("failed to encode squad position data for member name")) + } + ) + /** + * A failing pattern for when the coded value is not tied to a known field pattern. + * This pattern does not read or write any bit data. + * The `conditional` will always return `None` because + * its determining conditional statement is explicitly `false` + * and all cases involving explicit failure. + * @param code the unknown action code + */ + private def failureCodec(code : Int) : Codec[SquadPositionDetail] = conditional(included = false, bool).exmap[SquadPositionDetail] ( + _ => Attempt.failure(Err(s"decoding squad position data with unhandled codec - $code")), + _ => Attempt.failure(Err(s"encoding squad position data with unhandled codec - $code")) + ) + + /** + * Retrieve the field pattern by its associated action code. + * @param code the action code + * @param bitsOverByte a token maintaining stream misalignment for purposes of calculating string padding + * @return the field pattern + */ + private def selectCodedAction(code : Int, bitsOverByte : StreamLengthToken) : Codec[SquadPositionDetail] = { + code match { + case 0 => isClosedCodec + case 1 => roleCodec(bitsOverByte) + case 2 => ordersCodec(bitsOverByte) + case 3 => charIdCodec + case 4 => nameCodec(bitsOverByte) + case 5 => requirementsCodec + case _ => failureCodec(code) + } + } + + /** + * Advance information about the current stream length because on which pattern was previously utilized. + * @see `selectCodedAction(Int, StreamLengthToken)` + * @param code the action code, connecting to a field pattern + * @param bitsOverByte a token maintaining stream misalignment for purposes of calculating string padding + * @return a modified token maintaining stream misalignment + */ + private def modifyCodedPadValue(code : Int, bitsOverByte : StreamLengthToken) : StreamLengthToken = { + code match { + case 0 => bitsOverByte.Add(1) //additional 1u + case 1 => bitsOverByte.Length = 0 //byte-aligned string; padding zero'd + case 2 => bitsOverByte.Length = 0 //byte-aligned string; padding zero'd + case 3 => bitsOverByte //32u = no added padding + case 4 => bitsOverByte.Length = 0 //byte-aligned string; padding zero'd + case 5 => bitsOverByte.Add(6) //46u = 5*8u + 6u = additional 6u + case _ => bitsOverByte.Length = Int.MinValue //wildly incorrect + } + } + + /** + * Internal class for linked list operations. + * @param code action code indicating the squad position field + * @param info data for the squad position field + * @param next if there is a "next" squad position field + */ + private final case class LinkedFields(code : Int, info : SquadPositionDetail, next : Option[LinkedFields]) + + /** + * Transform a linked list of individual squad position field data into a combined squad position object. + * @param info the current section of the original linked list + * @param out the accumulative squad position data object + * @return the final squad position object output + */ + @tailrec + private def unlinkFields(info : Option[LinkedFields], out : SquadPositionDetail = SquadPositionDetail.Blank) : SquadPositionDetail = { + info match { + case None => + out + case Some(sqInfo) => + unlinkFields(sqInfo.next, out And sqInfo.info) + } + } + + /** + * Transform a squad position object whose field population may be sparse into a linked list of individual fields. + * Fields of the combined object are separated into a list of pairs + * of each of those fields's action codes and a squad position object with only that given field populated. + * After the blank entries are eliminated, the remaining fields are transformed into a linked list. + * @param info the combined squad position object + * @return the final linked list output + */ + private def linkFields(info : SquadPositionDetail) : LinkedFields = { + Seq( + (5, SquadPositionDetail(None, None, None, info.requirements, None, None)), + (4, SquadPositionDetail(None, None, None, None, None, info.name)), + (3, SquadPositionDetail(None, None, None, None, info.char_id, None)), + (2, SquadPositionDetail(None, None, info.detailed_orders, None, None, None)), + (1, SquadPositionDetail(None, info.role, None, None, None, None)), + (0, SquadPositionDetail(info.is_closed, None, None, None, None, None)) + ) //in reverse order so that the linked list is in the correct order + .filterNot { case (_, sqInfo) => sqInfo == SquadPositionDetail.Blank} + match { + case Nil => + throw new Exception("no linked list squad position fields encountered where at least one was expected") //bad end + case x :: Nil => + val (code, squadInfo) = x + LinkedFields(code, squadInfo, None) + case x :: xs => + val (code, squadInfo) = x + linkFields(xs, LinkedFields(code, squadInfo, None)) + } + } + + /** + * Transform a `List` of squad position field data paired with its field action code into a linked list. + * @param list the current subsection of the original list of fields + * @param out the accumulative linked list + * @return the final linked list output + */ + @tailrec + private def linkFields(list : Seq[(Int, SquadPositionDetail)], out : LinkedFields) : LinkedFields = { + if(list.isEmpty) { + out + } + else { + val (code, data) = list.head + linkFields(list.tail, LinkedFields(code, data, Some(out))) + } + } + + /** + * Parse each action code to determine the format of the following squad position field. + * Keep parsing until all reported squad position fields have been encountered. + * @param size the number of fields to be parsed + * @param bitsOverByte a token maintaining stream misalignment for purposes of calculating string padding + * @return a linked list composed of the squad position fields + */ + private def chain_linked_fields(size : Int, bitsOverByte : StreamLengthToken) : Codec[LinkedFields] = { + import shapeless.:: + ( + uint4 >>:~ { code => + selectCodedAction(code, bitsOverByte.Add(4)) >>:~ { _ => + modifyCodedPadValue(code, bitsOverByte) + conditional(size - 1 > 0, chain_linked_fields(size - 1, bitsOverByte)).hlist + } + } + ).xmap[LinkedFields] ( + { + case code :: entry :: next :: HNil => + LinkedFields(code, entry, next) + }, + { + case LinkedFields(code, entry, next) => + code :: entry :: next :: HNil + } + ) + } + + /** + * Parse the number of squad position fields anticipated and then start parsing those position fields. + * @param bitsOverByte a token maintaining stream misalignment for purposes of calculating string padding + */ + private def squad_member_details_codec(bitsOverByte : StreamLengthToken) : Codec[LinkedFields] = { + import shapeless.:: + ( + uint8 >>:~ { size => + chain_linked_fields(size, bitsOverByte).hlist + } + ).xmap[LinkedFields] ( + { + case _ :: info :: HNil => + info + }, + info => { + //count the linked position fields by tracing the "next" field in the linked list + var i = 1 + var dinfo = info + while(dinfo.next.nonEmpty) { + i += 1 + dinfo = dinfo.next.get + } + i :: info :: HNil + } + ) + } + + /** + * Entry point. + * @param bitsOverByte a token maintaining stream misalignment for purposes of calculating string padding + */ + def codec(bitsOverByte : StreamLengthToken) : Codec[SquadPositionEntry] = { + import shapeless.:: + ( + ("index" | uint8) >>:~ { index => + conditional(index < 255, bool :: squad_member_details_codec(bitsOverByte.Add(1))) :: + conditional(index == 255, bits) + } + ).xmap[SquadPositionEntry] ( + { + case 255 :: _ :: _ :: HNil => + SquadPositionEntry(255, None) + case ndx :: Some(_ :: info :: HNil) :: _ :: HNil => + SquadPositionEntry(ndx, Some(unlinkFields(Some(info)))) + }, + { + case SquadPositionEntry(255, _) => + 255 :: None :: None :: HNil + case SquadPositionEntry(ndx, Some(info)) => + ndx :: Some(true :: linkFields(info) :: HNil) :: None :: HNil + } + ) + } + } + + /** + * The patterns necessary to read enumerated squad position data. + * The main squad position data has been completed and now the squad's open positions are being parsed. + * These patterns split the difference between `FullSquad` operations and `ItemizedSquad` operations. + * Normally the whole of the squad position data is parsed in a single pass in `FullSquad` + * and, during `ItemizedSquad`, only piecemeal squad position fields are parsed. + * Furthermore, `FullSquad` position data is un-indexed because it is always presented in correct order, + * and `ItemizedSquad` positional data is indexed because it can skip entries and may be encountered in any order. + * These patterns parse full squad position data that is also indexed. + */ + object FullyPopulatedPositions { + /** + * The primary difference between the cores of `FullSquad` position data and `FullyPopulatedPositions` data, + * besides variable padding, + * involves the `requirements` field not having a basic set of values that are always masked. + * @param bitsOverByteLength a token maintaining stream misalignment for purposes of calculating string padding + */ + private def position_codec(bitsOverByteLength : Int) : Codec[SquadPositionDetail] = basePositionCodec(bitsOverByteLength, Set.empty) + + /** + * Internal class for linked list operations. + * @param index the current position's ordinal number + * @param info data for the squad position field + * @param next if there is a "next" squad position field + */ + private final case class LinkedFields(index: Int, info: SquadPositionDetail, next: Option[LinkedFields]) + + /** + * Transform a linked list of squad position data into a normal `List`. + * @param list the current section of the original linked list + * @param out the accumulative traditional `List` + * @return the final `List` output + */ + @tailrec + private def unlinkFields(list : LinkedFields, out : List[SquadPositionEntry] = Nil) : List[SquadPositionEntry] = { + list.next match { + case None => + out :+ SquadPositionEntry(list.index, list.info) + case Some(next) => + unlinkFields(next, out :+ SquadPositionEntry(list.index, list.info)) + } + } + + /** + * Transform a normal `List` of squad position data into a linked list. + * The original list becomes reversed in the process. + * @param list the original traditional `List` + * @return the final linked list output + */ + private def linkFields(list : List[SquadPositionEntry]) : LinkedFields = { + list match { + case Nil => + throw new Exception("") + case x :: xs if x.info.isEmpty => + linkFields(xs, LinkedFields(x.index, SquadPositionDetail.Blank, None)) + case x :: xs => + linkFields(xs, LinkedFields(x.index, x.info.get, None)) + } + } + + /** + * Transform a normal `List` of squad position data into a linked list. + * The original list becomes reversed in the process. + * @param list the current subsection of the original traditional `List` + * @param out the accumulative linked list + * @return the final linked list output + */ + @tailrec + private def linkFields(list : List[SquadPositionEntry], out : LinkedFields) : LinkedFields = { + list match { + case Nil => + out + case x :: _ if x.info.isEmpty => + LinkedFields(x.index, SquadPositionDetail.Blank, Some(out)) + case x :: Nil => + LinkedFields(x.index, x.info.get, Some(out)) + case x :: xs => + linkFields(xs, LinkedFields(x.index, x.info.get, Some(out))) + } + } + + /** + * All squad position entries asides from the first have unpadded strings. + * The very first entry aligns the remainder of the string fields along byte boundaries. + */ + private def subsequent_position_codec(size : Int) : Codec[LinkedFields] = { + import shapeless.:: + ( + uint8 >>:~ { index => + conditional(index < 255, bool :: position_codec(bitsOverByteLength = 0)) :: + conditional(size - 1 > 0, subsequent_position_codec(size - 1)) :: + conditional(index == 255, bits) + } + ).xmap[LinkedFields]( + { + case 255 :: _ :: _ :: _ :: HNil => + LinkedFields(255, SquadPositionDetail.Blank, None) + case index :: Some(_ :: entry :: HNil) :: next :: _ :: HNil => + LinkedFields(index, entry, next) + }, + { + case LinkedFields(255, _, _) => + 255 :: None :: None :: None :: HNil + case LinkedFields(index, entry, next) => + index :: Some(true :: entry :: HNil) :: next :: None :: HNil + } + ) + } + + /** + * The first squad position entry has its first string (`role`) field padded by an amount that can be determined. + * @param size the number of position entries to be parsed + * @param bitsOverByte a token maintaining stream misalignment for purposes of calculating string padding + */ + private def initial_position_codec(size : Int, bitsOverByte : StreamLengthToken) : Codec[LinkedFields] = { + import shapeless.:: + ( + uint8 >>:~ { index => + conditional(index < 255, { + bitsOverByte.Add(2) //1 (below) + 1 (position_codec) + bool :: position_codec(bitsOverByte.Length) + }) :: + conditional(index < 255 && size - 1 > 0, subsequent_position_codec(size - 1)) :: + conditional(index == 255, bits) + } + ).xmap[LinkedFields]( + { + case 255 :: _ :: _ :: _ :: HNil => + LinkedFields(255, SquadPositionDetail.Blank, None) + case index :: Some(_ :: entry :: HNil) :: next :: _ :: HNil => + LinkedFields(index, entry, next) + }, + { + case LinkedFields(255, _, _) => + 255 :: None :: None :: None :: HNil + case LinkedFields(index, entry, next) => + index :: Some(true :: entry :: HNil) :: next :: None :: HNil + } + ) + } + + /** + * Entry point. + * @param bitsOverByte a token maintaining stream misalignment for purposes of calculating string padding + */ + def codec(bitsOverByte : StreamLengthToken) : Codec[Vector[SquadPositionEntry]] = { + import shapeless.:: + ( + uint32L >>:~ { size => + bitsOverByte.Add(4) + uint4 :: + initial_position_codec(size.toInt + 1, bitsOverByte) + }).xmap[Vector[SquadPositionEntry]] ( + { + case _ :: _ :: linkedMembers :: HNil => + ignoreTerminatingEntry(unlinkFields(linkedMembers)).toVector + }, + memberList => 10 :: 12 :: linkFields(ensureTerminatingEntry(memberList.toList).reverse) :: HNil + ) + } + } + + /** + * Certification values that are front-loaded into the `FullSquad` operations for finding squad position requirements. + * In the game proper, these are three certification values that the user can not give up or interact with. + */ + final val DefaultRequirements : Set[CertificationType.Value] = Set( + CertificationType.StandardAssault, + CertificationType.StandardExoSuit, + CertificationType.AgileExoSuit + ) + + /** + * Blank squad data set up for `FullSquad` parsing. + * The `guid` value is significant - it represents the client-local squad data. + */ + final val Init = SquadDetailDefinitionUpdateMessage(PlanetSideGUID(0), SquadDetail().Complete) + + /** + * Produces a byte-aligned Pascal strings encoded through common manipulations. + * Rather than pass in the amount of the padding directly, however, + * the stream length or the misalignment to the stream's previous byte boundary is passed into the function + * and is converted into the proper padding value. + * @see `PacketHelpers.encodedWideStringAligned` + * @param bitsOverByte the number of bits past the previous byte-aligned index; + * gets converted to a 0-7 string padding number based on how many bits remain befoire the next byte + * @return the padded string `Codec` + */ + private def paddedStringMetaCodec(bitsOverByte : Int) : Codec[String] = PacketHelpers.encodedWideStringAligned({ + val mod8 = bitsOverByte % 8 + if(mod8 == 0) { + 0 + } + else { + 8 - mod8 + } + }) + + /** + * Pattern for reading all of the fields for squad position data. + * @param bitsOverByteLength the number of bits past the previous byte-aligned index + * @param defaultRequirements `CertificationType` values that are automatically masked in the `requirements` field + */ + private def basePositionCodec(bitsOverByteLength : Int, defaultRequirements : Set[CertificationType.Value]) : Codec[SquadPositionDetail] = { + import shapeless.:: + ( + uint8 :: //required value = 6 + ("is_closed" | bool) :: //if all positions are closed, the squad detail menu display no positions at all + ("role" | paddedStringMetaCodec(bitsOverByteLength)) :: + ("detailed_orders" | PacketHelpers.encodedWideString) :: + ("char_id" | uint32L) :: + ("name" | PacketHelpers.encodedWideString) :: + ("requirements" | ulongL(46)) + ).exmap[SquadPositionDetail] ( + { + case 6 :: closed :: role :: orders :: char_id :: name :: requirements :: HNil => + Attempt.Successful( + SquadPositionDetail(Some(closed), Some(role), Some(orders), Some(defaultRequirements ++ CertificationType.fromEncodedLong(requirements)), Some(char_id), Some(name)) + ) + case data => + Attempt.Failure(Err(s"can not decode a SquadDetailDefinitionUpdate member's data - $data")) + }, + { + case SquadPositionDetail(Some(closed), Some(role), Some(orders), Some(requirements), Some(char_id), Some(name)) => + Attempt.Successful(6 :: closed :: role :: orders :: char_id :: name :: CertificationType.toEncodedLong(defaultRequirements ++ requirements) :: HNil) + } + ) + } + + /** + * The last entry in the sequence of squad information listings should be a dummied listing with an index of 255. + * Ensure that this terminal entry is located at the end. + * @param list the listing of squad information + * @return the listing of squad information, with a specific final entry + */ + private def ensureTerminatingEntry(list : List[SquadPositionEntry]) : List[SquadPositionEntry] = { + list.lastOption match { + case Some(SquadPositionEntry(255, _)) => list + case Some(_) | None => list :+ SquadPositionEntry(255, None) + } + } + + /** + * The last entry in the sequence of squad information listings should be a dummied listing with an index of 255. + * Remove this terminal entry from the end of the list so as not to hassle with it. + * @param list the listing of squad information + * @return the listing of squad information, with a specific final entry truncated + */ + private def ignoreTerminatingEntry(list : List[SquadPositionEntry]) : List[SquadPositionEntry] = { + list.lastOption match { + case Some(SquadPositionEntry(255, _)) => list.init + case Some(_) | None => list + } + } + + implicit val codec : Codec[SquadDetailDefinitionUpdateMessage] = { + import shapeless.:: + import net.psforever.newcodecs.newcodecs + ( + ("guid" | PlanetSideGUID.codec) :: + bool :: + (uint8 >>:~ { size => + newcodecs.binary_choice( + size == 9, + FullSquad.codec, + ItemizedSquad.codec(size) + ).hlist + }) + ).exmap[SquadDetailDefinitionUpdateMessage] ( + { + case guid :: _ :: _ :: info :: HNil => + Attempt.Successful(SquadDetailDefinitionUpdateMessage(guid, info)) + }, + { + case SquadDetailDefinitionUpdateMessage(guid, info) => + val occupiedSquadFieldCount = List(info.unk1, info.unk2, info.leader_char_id, info.unk3, info.leader_name, info.task, info.zone_id, info.unk7, info.member_info) + .count(_.nonEmpty) + if(occupiedSquadFieldCount < 9) { + //itemized detail definition protocol + Attempt.Successful(guid :: true :: occupiedSquadFieldCount :: info :: HNil) + } + else { + //full squad detail definition protocol + Attempt.Successful(guid :: true :: 9 :: info :: HNil) + } + } + ) + } +} diff --git a/common/src/main/scala/net/psforever/packet/game/SquadInvitationRequestMessage.scala b/common/src/main/scala/net/psforever/packet/game/SquadInvitationRequestMessage.scala new file mode 100644 index 000000000..60de66e19 --- /dev/null +++ b/common/src/main/scala/net/psforever/packet/game/SquadInvitationRequestMessage.scala @@ -0,0 +1,41 @@ +// Copyright (c) 2019 PSForever +package net.psforever.packet.game + +import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket} +import scodec.Codec +import scodec.codecs._ + +/** + * A message for communicating squad invitation. + * When received by a client, the event message "You have invited `name` to join your squad" is produced + * and a `SquadMembershipRequest` packet of type `Invite` + * using `char_id` as the optional unique character identifier field is dispatched to the server. + * The message is equivalent to a dispatched packet of type `SquadMembershipResponse` + * with an `Invite` event with the referral field set to `true`. + * @see `SquadMembershipResponse` + * @param squad_guid the squad's GUID + * @param slot a potentially valid slot index; + * 0-9; higher numbers produce no response + * @param char_id the unique character identifier + * @param name the character's name; + * frequently, though that does not produce a coherent message, + * the avatar's own name is supplied in the event message instead of the name of another player + */ +final case class SquadInvitationRequestMessage(squad_guid : PlanetSideGUID, + slot : Int, + char_id : Long, + name : String) + extends PlanetSideGamePacket { + type Packet = SquadInvitationRequestMessage + def opcode = GamePacketOpcode.SquadInvitationRequestMessage + def encode = SquadInvitationRequestMessage.encode(this) +} + +object SquadInvitationRequestMessage extends Marshallable[SquadInvitationRequestMessage] { + implicit val codec : Codec[SquadInvitationRequestMessage] = ( + ("squad_guid" | PlanetSideGUID.codec) :: + ("slot" | uint4) :: + ("char_id" | uint32L) :: + ("name" | PacketHelpers.encodedWideStringAligned(adjustment = 4)) + ).as[SquadInvitationRequestMessage] +} diff --git a/common/src/main/scala/net/psforever/packet/game/SquadMemberEvent.scala b/common/src/main/scala/net/psforever/packet/game/SquadMemberEvent.scala new file mode 100644 index 000000000..51b749559 --- /dev/null +++ b/common/src/main/scala/net/psforever/packet/game/SquadMemberEvent.scala @@ -0,0 +1,81 @@ +// Copyright (c) 2019 PSForever +package net.psforever.packet.game + +import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket} +import scodec.{Attempt, Codec, Err} +import scodec.codecs._ +import shapeless.{::, HNil} + +object MemberEvent extends Enumeration { + type Type = Value + + val + Add, + Remove, + Promote, + UpdateZone, + Unknown4 + = Value + + implicit val codec = PacketHelpers.createEnumerationCodec(enum = this, uint(bits = 3)) +} + +final case class SquadMemberEvent(action : MemberEvent.Value, + unk2 : Int, + char_id : Long, + position : Int, + player_name : Option[String], + zone_number : Option[Int], + unk7 : Option[Long]) + extends PlanetSideGamePacket { + type Packet = SquadMemberEvent + def opcode = GamePacketOpcode.SquadMemberEvent + def encode = SquadMemberEvent.encode(this) +} + +object SquadMemberEvent extends Marshallable[SquadMemberEvent] { + def apply(action : MemberEvent.Value, unk2 : Int, char_id : Long, position : Int) : SquadMemberEvent = + SquadMemberEvent(action, unk2, char_id, position, None, None, None) + + def Add(unk2 : Int, char_id : Long, position : Int, player_name : String, zone_number : Int, unk7 : Long) : SquadMemberEvent = + SquadMemberEvent(MemberEvent.Add, unk2, char_id, position, Some(player_name), Some(zone_number), Some(unk7)) + + def Remove(unk2 : Int, char_id : Long, position : Int) : SquadMemberEvent = + SquadMemberEvent(MemberEvent.Remove, unk2, char_id, position, None, None, None) + + def Promote(unk2 : Int, char_id : Long) : SquadMemberEvent = + SquadMemberEvent(MemberEvent.Promote, unk2, char_id, 0, None, None, None) + + def UpdateZone(unk2 : Int, char_id : Long, position : Int, zone_number : Int) : SquadMemberEvent = + SquadMemberEvent(MemberEvent.UpdateZone, unk2, char_id, position, None, Some(zone_number), None) + + def Unknown4(unk2 : Int, char_id : Long, position : Int, unk7 : Long) : SquadMemberEvent = + SquadMemberEvent(MemberEvent.Unknown4, unk2, char_id, position, None, None, Some(unk7)) + + implicit val codec : Codec[SquadMemberEvent] = ( + ("action" | MemberEvent.codec) >>:~ { action => + ("unk2" | uint16L) :: + ("char_id" | uint32L) :: + ("position" | uint4) :: + conditional(action == MemberEvent.Add, "player_name" | PacketHelpers.encodedWideStringAligned(1)) :: + conditional(action == MemberEvent.Add || action == MemberEvent.UpdateZone, "zone_number" | uint16L) :: + conditional(action == MemberEvent.Add || action == MemberEvent.Unknown4, "unk7" | uint32L) + }).exmap[SquadMemberEvent] ( + { + case action :: unk2 :: char_id :: member_position :: player_name :: zone_number :: unk7 :: HNil => + Attempt.Successful(SquadMemberEvent(action, unk2, char_id, member_position, player_name, zone_number, unk7)) + }, + { + case SquadMemberEvent(MemberEvent.Add, unk2, char_id, member_position, Some(player_name), Some(zone_number), Some(unk7)) => + Attempt.Successful(MemberEvent.Add :: unk2 :: char_id :: member_position :: Some(player_name) :: Some(zone_number) :: Some(unk7) :: HNil) + case SquadMemberEvent(MemberEvent.UpdateZone, unk2, char_id, member_position, None, Some(zone_number), None) => + Attempt.Successful(MemberEvent.UpdateZone :: unk2 :: char_id :: member_position :: None :: Some(zone_number) :: None :: HNil) + case SquadMemberEvent(MemberEvent.Unknown4, unk2, char_id, member_position, None, None, Some(unk7)) => + Attempt.Successful(MemberEvent.Unknown4 :: unk2 :: char_id :: member_position :: None :: None :: Some(unk7) :: HNil) + case SquadMemberEvent(action, unk2, char_id, member_position, None, None, None) => + Attempt.Successful(action :: unk2 :: char_id :: member_position :: None :: None :: None :: HNil) + case data => + Attempt.Failure(Err(s"SquadMemberEvent can not encode with this pattern - $data")) + } + ) +} diff --git a/common/src/main/scala/net/psforever/packet/game/SquadMembershipRequest.scala b/common/src/main/scala/net/psforever/packet/game/SquadMembershipRequest.scala new file mode 100644 index 000000000..c5bd3550c --- /dev/null +++ b/common/src/main/scala/net/psforever/packet/game/SquadMembershipRequest.scala @@ -0,0 +1,57 @@ +// Copyright (c) 2019 PSForever +package net.psforever.packet.game + +import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket} +import net.psforever.types.SquadRequestType +import scodec.Codec +import scodec.codecs._ + +/** + * Dispatched by the client as manipulation protocol for squad and platoon members. + * Answerable by a `SquadMembershipResponse` packet. + * @param request_type the purpose of the request + * @param char_id a squad member unique identifier; + * usually, the player being addresses by thie packet + * @param unk3 na + * @param player_name name of the player being affected, if applicable + * @param unk5 na + */ +final case class SquadMembershipRequest(request_type : SquadRequestType.Value, + char_id : Long, + unk3 : Option[Long], + player_name : String, + unk5 : Option[Option[String]]) + extends PlanetSideGamePacket { + request_type match { + case SquadRequestType.Accept | SquadRequestType.Reject | SquadRequestType.Disband | + SquadRequestType.PlatoonAccept | SquadRequestType.PlatoonReject | SquadRequestType.PlatoonDisband => + assert(unk3.isEmpty, s"a $request_type request requires the unk3 field be undefined") + case _ => + assert(unk3.nonEmpty, s"a $request_type request requires the unk3 field be defined") + } + if(request_type == SquadRequestType.Invite) { + assert(unk5.nonEmpty, "an Invite request requires the unk5 field be defined") + } + + type Packet = SquadMembershipRequest + def opcode = GamePacketOpcode.SquadMembershipRequest + def encode = SquadMembershipRequest.encode(this) +} + +object SquadMembershipRequest extends Marshallable[SquadMembershipRequest] { + implicit val codec : Codec[SquadMembershipRequest] = ( + ("request_type" | SquadRequestType.codec) >>:~ { request_type => + ("unk2" | uint32L) :: + conditional(request_type != SquadRequestType.Accept && + request_type != SquadRequestType.Reject && + request_type != SquadRequestType.Disband && + request_type != SquadRequestType.PlatoonAccept && + request_type != SquadRequestType.PlatoonReject && + request_type != SquadRequestType.PlatoonDisband, "unk3" | uint32L) :: + (("player_name" | PacketHelpers.encodedWideStringAligned(4)) >>:~ { pname => + conditional(request_type == SquadRequestType.Invite, + "unk5" | optional(bool, PacketHelpers.encodedWideStringAligned({if(pname.length == 0) 3 else 7})) + ).hlist + }) + }).as[SquadMembershipRequest] +} diff --git a/common/src/main/scala/net/psforever/packet/game/SquadMembershipResponse.scala b/common/src/main/scala/net/psforever/packet/game/SquadMembershipResponse.scala new file mode 100644 index 000000000..a9f035183 --- /dev/null +++ b/common/src/main/scala/net/psforever/packet/game/SquadMembershipResponse.scala @@ -0,0 +1,96 @@ +// Copyright (c) 2019 PSForever +package net.psforever.packet.game + +import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket} +import net.psforever.types.SquadResponseType +import scodec.Codec +import scodec.codecs._ + +/** + * Dispatched by the server as message generation protocol for squad and platoon members. + * Prompted by and answers for a `SquadMembershipRequest` packet. + * @param request_type the purpose of the request + * @param unk1 na + * @param unk2 na + * @param char_id a squad member unique identifier; + * usually, the player being addresses by thie packet + * @param other_id another squad member's unique identifier; + * may be the same as `char_id` + * @param player_name name of the player being affected, if applicable + * @param unk5 adjusts the nature of the request-type response based on the message recipient + * @param unk6 na; + * the internal field, the `Option[String]`, never seems to be set + *
+ * `request_type` (enum value) / `unk5` state (`false`/`true`)
+ * ----------------------------------------
+ * - `Invite` (0)
+ * false => [PROMPT] "`player_name` has invited you into a squad." [YES/NO]
+ * true => "You have invited `player_name` to join your squad."
+ * - `Unk01` (1)
+ * false => n/a
+ * true => n/a
+ * - `Accept` (2)
+ * false => "`player_name` has accepted your invitation to join into your squad.
+ * "You have formed a squad and are now that squad's commander." (if first time)
+ * true => "You have accepted an invitation to join a squad."
+ * "You have successfully joined a squad for the first time." (if first time)
+ * - `Reject` (3)
+ * false => "`player_name` does not want to join your squad at this time."
+ * true => "You have declined an invitation to join a squad."
+ * - `Cancel` (4)
+ * false => "`player_name` has withdrawn his invitation."
+ * true => "You have canceled your invitation to `player_name`."
+ * - `Leave` (5)
+ * false => "The Squad Leader has kicked you out of the squad."
+ * true => "You have kicked `player_name` out of the squad."
+ * - `Disband` (6)
+ * false => "The squad has been disbanded."
+ * true => "You have disbanded the squad."
+ * - `PlatoonInvite` (7)
+ * false => [PROMPT] "`player_name` has invited you into a platoon." [YES/NO]
+ * true => "You have invited `player_name`'s squad to join your platoon."
+ * - `PlatoonAccept` (8) + * false => "`player_name` has accepted your invitation to join into your platoon.
+ * "You have formed a platoon and are now that platoon commander." (if first time)
+ * true => "You have accepted an invitation to join a platoon."
+ * "You have successfully joined a platoon for the first time." (if first time)
+ * - `PlatoonReject` (9)
+ * false => "`player_name` does not want to join your platoon at this time."
+ * true => "You have declined an invitation to join a platoon."
+ * - `PlatoonCancel` (10)
+ * false => "`player_name` has withdrawn his invitation."
+ * true => "You have declined your invitation to `player_name`." (nonsense?)
+ * - `PlatoonLeave` (11)
+ * false => "The Platoon Leader has kicked you out of the platoon."
+ * true => "You have kicked `player_name`'s squad out of the platoon."
+ * - `PlatoonDisband` (12)
+ * false => "The platoon has been disbanded."
+ * true => "You have disbanded the platoon." + */ +final case class SquadMembershipResponse(request_type : SquadResponseType.Value, + unk1 : Int, + unk2 : Int, + char_id : Long, + other_id : Option[Long], + player_name : String, + unk5 : Boolean, + unk6 : Option[Option[String]]) + extends PlanetSideGamePacket { + type Packet = SquadMembershipResponse + def opcode = GamePacketOpcode.SquadMembershipResponse + def encode = SquadMembershipResponse.encode(this) +} + +object SquadMembershipResponse extends Marshallable[SquadMembershipResponse] { + implicit val codec : Codec[SquadMembershipResponse] = ( + "request_type" | SquadResponseType.codec >>:~ { d => + ("unk1" | uint(5)) :: + ("unk2" | uint2) :: + ("char_id" | uint32L) :: + ("other_id" | conditional(d != SquadResponseType.Disband && d != SquadResponseType.PlatoonDisband, uint32L)) :: + ("player_name" | PacketHelpers.encodedWideStringAligned(5)) :: + ("unk5" | bool) :: + conditional(d != SquadResponseType.Invite, optional(bool, "unk6" | PacketHelpers.encodedWideStringAligned(6))) + } + ).as[SquadMembershipResponse] +} diff --git a/common/src/main/scala/net/psforever/packet/game/SquadState.scala b/common/src/main/scala/net/psforever/packet/game/SquadState.scala new file mode 100644 index 000000000..dc0927494 --- /dev/null +++ b/common/src/main/scala/net/psforever/packet/game/SquadState.scala @@ -0,0 +1,101 @@ +// Copyright (c) 2019 PSForever +package net.psforever.packet.game + +import net.psforever.packet.{GamePacketOpcode, Marshallable, PlanetSideGamePacket} +import net.psforever.types.Vector3 +import scodec.{Attempt, Codec, Err} +import scodec.codecs._ +import shapeless.{::, HNil} + +/** + * Information about a specific squad member. + * @param char_id the character's unique identifier + * @param health the character's health value percentage, divided into 64 units + * @param armor the character's armor value percentage, divided into 64 units + * @param pos the world coordinates of the character + * @param unk4 na; + * usually, 2 + * @param unk5 na; + * usually, 2 + * @param unk6 na; + * usually, false + * @param unk7 na + * @param unk8 na; + * if defined, will be defined with unk9 + * @param unk9 na; + * if defined, will be defined with unk8 + */ +final case class SquadStateInfo(char_id : Long, + health : Int, + armor : Int, + pos : Vector3, + unk4 : Int, + unk5 : Int, + unk6 : Boolean, + unk7 : Int, + unk8 : Option[Int], + unk9 : Option[Boolean]) + +/** + * Dispatched by the server to update a squad member's representative icons on the continental maps and the interstellar map.
+ *
+ * This packet must be preceded by the correct protocol + * to assign any character who is defined by `char_id` in `info_list` + * as a member of this client's player's assigned squad by means of associating that said `char_id`. + * The said preceding protocol also assigns the player's current zone (continent) and their ordinal position in the squad. + * @see `SquadMemberEvent` + * @param guid the squad's unique identifier; + * must be consistent per packet on a given client; + * does not have to be the global uid of the squad as according to the server + * @param info_list information about the members in this squad who will be updated + */ +final case class SquadState(guid : PlanetSideGUID, + info_list : List[SquadStateInfo]) + extends PlanetSideGamePacket { + type Packet = SquadState + def opcode = GamePacketOpcode.SquadState + def encode = SquadState.encode(this) +} + +object SquadStateInfo { + def apply(unk1 : Long, unk2 : Int, unk3 : Int, pos : Vector3, unk4 : Int, unk5 : Int, unk6 : Boolean, unk7 : Int) : SquadStateInfo = + SquadStateInfo(unk1, unk2, unk3, pos, unk4, unk5, unk6, unk7, None, None) + + def apply(unk1 : Long, unk2 : Int, unk3 : Int, pos : Vector3, unk4 : Int, unk5 : Int, unk6 : Boolean, unk7 : Int, unk8 : Int, unk9 : Boolean) : SquadStateInfo = + SquadStateInfo(unk1, unk2, unk3, pos, unk4, unk5, unk6, unk7, Some(unk8), Some(unk9)) +} + +object SquadState extends Marshallable[SquadState] { + private val info_codec : Codec[SquadStateInfo] = ( + ("char_id" | uint32L) :: + ("health" | uint(7)) :: + ("armor" | uint(7)) :: + ("pos" | Vector3.codec_pos) :: + ("unk4" | uint2) :: + ("unk5" | uint2) :: + ("unk6" | bool) :: + ("unk7" | uint16L) :: + (bool >>:~ { out => + conditional(out, "unk8" | uint16L) :: + conditional(out, "unk9" | bool) + }) + ).exmap[SquadStateInfo] ( + { + case char_id :: health :: armor :: pos :: u4 :: u5 :: u6 :: u7 :: _ :: u8 :: u9 :: HNil => + Attempt.Successful(SquadStateInfo(char_id, health, armor, pos, u4, u5, u6, u7, u8, u9)) + }, + { + case SquadStateInfo(char_id, health, armor, pos, u4, u5, u6, u7, Some(u8), Some(u9)) => + Attempt.Successful(char_id :: health :: armor :: pos :: u4 :: u5 :: u6 :: u7 :: true :: Some(u8) :: Some(u9) :: HNil) + case SquadStateInfo(char_id, health, armor, pos, u4, u5, u6, u7, None, None) => + Attempt.Successful(char_id :: health :: armor :: pos :: u4 :: u5 :: u6 :: u7 :: false :: None :: None :: HNil) + case data @ (SquadStateInfo(_, _, _, _, _, _, _, _, Some(_), None) | SquadStateInfo(_, _, _, _, _, _, _, _, None, Some(_))) => + Attempt.Failure(Err(s"SquadStateInfo requires both unk8 and unk9 to be either defined or undefined at the same time - $data")) + } + ) + + implicit val codec : Codec[SquadState] = ( + ("guid" | PlanetSideGUID.codec) :: + ("info_list" | listOfN(uint4, info_codec)) + ).as[SquadState] +} diff --git a/common/src/main/scala/net/psforever/packet/game/SquadWaypointEvent.scala b/common/src/main/scala/net/psforever/packet/game/SquadWaypointEvent.scala index 3874b625a..ff466782e 100644 --- a/common/src/main/scala/net/psforever/packet/game/SquadWaypointEvent.scala +++ b/common/src/main/scala/net/psforever/packet/game/SquadWaypointEvent.scala @@ -1,22 +1,22 @@ -// Copyright (c) 2017 PSForever +// Copyright (c) 2019 PSForever package net.psforever.packet.game import net.psforever.packet.{GamePacketOpcode, Marshallable, PlanetSideGamePacket} -import net.psforever.types.Vector3 +import net.psforever.types.{SquadWaypoints, Vector3} import scodec.{Attempt, Codec, Err} import scodec.codecs._ import shapeless.{::, HNil} -final case class WaypointEvent(unk1 : Int, +final case class WaypointEvent(zone_number : Int, pos : Vector3, - unk2 : Int) + unk : Int) -final case class SquadWaypointEvent(unk1 : Int, - unk2 : Int, - unk3 : Long, - unk4 : Int, +final case class SquadWaypointEvent(event_type : WaypointEventAction.Value, + unk : Int, + char_id : Long, + waypoint_type : SquadWaypoints.Value, unk5 : Option[Long], - unk6 : Option[WaypointEvent]) + waypoint_info : Option[WaypointEvent]) extends PlanetSideGamePacket { type Packet = SquadWaypointEvent def opcode = GamePacketOpcode.SquadWaypointEvent @@ -24,55 +24,55 @@ final case class SquadWaypointEvent(unk1 : Int, } object SquadWaypointEvent extends Marshallable[SquadWaypointEvent] { - def apply(unk1 : Int, unk2 : Int, unk3 : Long, unk4 : Int, unk_a : Long) : SquadWaypointEvent = - SquadWaypointEvent(unk1, unk2, unk3, unk4, Some(unk_a), None) + def Add(unk : Int, char_id : Long, waypoint_type : SquadWaypoints.Value, waypoint : WaypointEvent) : SquadWaypointEvent = + SquadWaypointEvent(WaypointEventAction.Add, unk, char_id, waypoint_type, None, Some(waypoint)) - def apply(unk1 : Int, unk2 : Int, unk3 : Long, unk4 : Int, unk_a : Int, pos : Vector3, unk_b : Int) : SquadWaypointEvent = - SquadWaypointEvent(unk1, unk2, unk3, unk4, None, Some(WaypointEvent(unk_a, pos, unk_b))) + def Unknown1(unk : Int, char_id : Long, waypoint_type : SquadWaypoints.Value, unk_a : Long) : SquadWaypointEvent = + SquadWaypointEvent(WaypointEventAction.Unknown1, unk, char_id, waypoint_type, Some(unk_a), None) - def apply(unk1 : Int, unk2 : Int, unk3 : Long, unk4 : Int) : SquadWaypointEvent = - SquadWaypointEvent(unk1, unk2, unk3, unk4, None, None) + def Remove(unk : Int, char_id : Long, waypoint_type : SquadWaypoints.Value) : SquadWaypointEvent = + SquadWaypointEvent(WaypointEventAction.Remove, unk, char_id, waypoint_type, None, None) private val waypoint_codec : Codec[WaypointEvent] = ( - ("unk1" | uint16L) :: + ("zone_number" | uint16L) :: ("pos" | Vector3.codec_pos) :: - ("unk2" | uint(3)) + ("unk" | uint(3)) ).as[WaypointEvent] implicit val codec : Codec[SquadWaypointEvent] = ( - ("unk1" | uint2) >>:~ { unk1 => - ("unk2" | uint16L) :: - ("unk3" | uint32L) :: - ("unk4" | uint8L) :: - ("unk5" | conditional(unk1 == 1, uint32L)) :: - ("unk6" | conditional(unk1 == 0, waypoint_codec)) + ("event_type" | WaypointEventAction.codec) >>:~ { event_type => + ("unk" | uint16L) :: + ("char_id" | uint32L) :: + ("waypoint_type" | SquadWaypoints.codec) :: + ("unk5" | conditional(event_type == WaypointEventAction.Unknown1, uint32L)) :: + ("waypoint_info" | conditional(event_type == WaypointEventAction.Add, waypoint_codec)) } ).exmap[SquadWaypointEvent] ( { - case 0 :: a :: b :: c :: None :: Some(d) :: HNil => - Attempt.Successful(SquadWaypointEvent(0, a, b, c, None, Some(d))) + case WaypointEventAction.Add :: a :: char_id :: waypoint_type :: None :: Some(waypoint) :: HNil => + Attempt.Successful(SquadWaypointEvent(WaypointEventAction.Add, a, char_id, waypoint_type, None, Some(waypoint))) - case 1 :: a :: b :: c :: Some(d) :: None :: HNil => - Attempt.Successful(SquadWaypointEvent(1, a, b, c, Some(d), None)) + case WaypointEventAction.Unknown1 :: a :: char_id :: waypoint_type :: Some(d) :: None :: HNil => + Attempt.Successful(SquadWaypointEvent(WaypointEventAction.Unknown1, a, char_id, waypoint_type, Some(d), None)) - case a :: b :: c :: d :: None :: None :: HNil => - Attempt.Successful(SquadWaypointEvent(a, b, c, d, None, None)) + case event_type :: b :: char_id :: waypoint_type :: None :: None :: HNil => + Attempt.Successful(SquadWaypointEvent(event_type, b, char_id, waypoint_type, None, None)) - case n :: _ :: _ :: _ :: _ :: _ :: HNil => - Attempt.Failure(Err(s"unexpected format for unk1 - $n")) + case data => + Attempt.Failure(Err(s"unexpected format for $data")) }, { - case SquadWaypointEvent(0, a, b, c, None, Some(d)) => - Attempt.Successful(0 :: a :: b :: c :: None :: Some(d) :: HNil) + case SquadWaypointEvent(WaypointEventAction.Add, a, char_id, waypoint_type, None, Some(waypoint)) => + Attempt.Successful(WaypointEventAction.Add :: a :: char_id :: waypoint_type :: None :: Some(waypoint) :: HNil) - case SquadWaypointEvent(1, a, b, c, Some(d), None) => - Attempt.Successful(1 :: a :: b :: c :: Some(d) :: None :: HNil) + case SquadWaypointEvent(WaypointEventAction.Unknown1, a, char_id, waypoint_type, Some(d), None) => + Attempt.Successful(WaypointEventAction.Unknown1 :: a :: char_id :: waypoint_type :: Some(d) :: None :: HNil) - case SquadWaypointEvent(a, b, c, d, None, None) => - Attempt.Successful(a :: b :: c :: d :: None :: None :: HNil) + case SquadWaypointEvent(event_type, b, char_id, waypoint_type, None, None) => + Attempt.Successful(event_type :: b :: char_id :: waypoint_type :: None :: None :: HNil) - case SquadWaypointEvent(n, _, _, _, _, _) => - Attempt.Failure(Err(s"unexpected format for unk1 - $n")) + case data => + Attempt.Failure(Err(s"unexpected format for $data")) } ) } diff --git a/common/src/main/scala/net/psforever/packet/game/SquadWaypointRequest.scala b/common/src/main/scala/net/psforever/packet/game/SquadWaypointRequest.scala new file mode 100644 index 000000000..3384f8b50 --- /dev/null +++ b/common/src/main/scala/net/psforever/packet/game/SquadWaypointRequest.scala @@ -0,0 +1,115 @@ +// Copyright (c) 2019 PSForever +package net.psforever.packet.game + +import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket} +import net.psforever.types.{SquadWaypoints, Vector3} +import scodec.{Attempt, Codec, Err} +import scodec.codecs._ +import shapeless.{::, HNil} + +/** + * Actions that can be requested of the specific waypoint. + */ +object WaypointEventAction extends Enumeration { + type Type = Value + + val + Add, + Unknown1, + Remove, + Unknown3 //unconfirmed + = Value + + implicit val codec : Codec[WaypointEventAction.Value] = PacketHelpers.createEnumerationCodec(enum = this, uint2) +} + +/** + * na + * @param zone_number the zone + * @param pos the continental map coordinate location of the waypoint; + * the z-coordinate is almost always 0.0 + */ +final case class WaypointInfo(zone_number : Int, + pos : Vector3) + +/** + * na + * @param request_type the action to be performed + * @param char_id the unique id of player setting the waypoint + * @param waypoint_type the waypoint being updated; + * 0-3 for the standard squad waypoints numbered "1-4"; + * 4 for the squad leader experience waypoint; + * cycles through 0-3 continuously + * + * @param unk4 na + * @param waypoint_info essential data about the waypoint + */ +final case class SquadWaypointRequest(request_type : WaypointEventAction.Value, + char_id : Long, + waypoint_type : SquadWaypoints.Value, + unk4 : Option[Long], + waypoint_info : Option[WaypointInfo]) + extends PlanetSideGamePacket { + type Packet = SquadWaypointRequest + def opcode = GamePacketOpcode.SquadWaypointRequest + def encode = SquadWaypointRequest.encode(this) +} + +object SquadWaypointRequest extends Marshallable[SquadWaypointRequest] { + def Add(char_id : Long, waypoint_type : SquadWaypoints.Value, waypoint : WaypointInfo) : SquadWaypointRequest = + SquadWaypointRequest(WaypointEventAction.Add, char_id, waypoint_type, None, Some(waypoint)) + + def Unknown1(char_id : Long, waypoint_type : SquadWaypoints.Value, unk_a : Long) : SquadWaypointRequest = + SquadWaypointRequest(WaypointEventAction.Unknown1, char_id, waypoint_type, Some(unk_a), None) + + def Remove(char_id : Long, waypoint_type : SquadWaypoints.Value) : SquadWaypointRequest = + SquadWaypointRequest(WaypointEventAction.Remove, char_id, waypoint_type, None, None) + + private val waypoint_codec : Codec[WaypointInfo] = ( + ("zone_number" | uint16L) :: + ("pos" | Vector3.codec_pos) + ).xmap[WaypointInfo] ( + { + case zone_number :: pos :: HNil => WaypointInfo(zone_number, pos) + }, + { + case WaypointInfo(zone_number, pos) => zone_number :: pos.xy :: HNil + } + ) + + implicit val codec : Codec[SquadWaypointRequest] = ( + ("request_type" | WaypointEventAction.codec) >>:~ { request_type => + ("char_id" | uint32L) :: + ("waypoint_type" | SquadWaypoints.codec) :: + ("unk4" | conditional(request_type == WaypointEventAction.Unknown1, uint32L)) :: + ("waypoint" | conditional(request_type == WaypointEventAction.Add, waypoint_codec)) + } + ).exmap[SquadWaypointRequest] ( + { + case WaypointEventAction.Add :: char_id :: waypoint_type :: None :: Some(waypoint) :: HNil => + Attempt.Successful(SquadWaypointRequest(WaypointEventAction.Add, char_id, waypoint_type, None, Some(waypoint))) + + case WaypointEventAction.Unknown1 :: char_id :: waypoint_type :: Some(d) :: None :: HNil => + Attempt.Successful(SquadWaypointRequest(WaypointEventAction.Unknown1, char_id, waypoint_type, Some(d), None)) + + case request_type :: char_id :: waypoint_type :: None :: None :: HNil => + Attempt.Successful(SquadWaypointRequest(request_type, char_id, waypoint_type, None, None)) + + case data => + Attempt.Failure(Err(s"unexpected format while decoding - $data")) + }, + { + case SquadWaypointRequest(WaypointEventAction.Add, char_id, waypoint_type, None, Some(waypoint)) => + Attempt.Successful(WaypointEventAction.Add :: char_id :: waypoint_type :: None :: Some(waypoint) :: HNil) + + case SquadWaypointRequest(WaypointEventAction.Unknown1, char_id, waypoint_type, Some(d), None) => + Attempt.Successful(WaypointEventAction.Unknown1 :: char_id :: waypoint_type :: Some(d) :: None :: HNil) + + case SquadWaypointRequest(request_type, char_id, waypoint_type, None, None) => + Attempt.Successful(request_type :: char_id :: waypoint_type :: None :: None :: HNil) + + case data : SquadWaypointRequest => + Attempt.Failure(Err(s"unexpected format while encoding - $data")) + } + ) +} diff --git a/common/src/main/scala/net/psforever/packet/game/objectcreate/CharacterAppearanceData.scala b/common/src/main/scala/net/psforever/packet/game/objectcreate/CharacterAppearanceData.scala index c2b064d37..c3f179217 100644 --- a/common/src/main/scala/net/psforever/packet/game/objectcreate/CharacterAppearanceData.scala +++ b/common/src/main/scala/net/psforever/packet/game/objectcreate/CharacterAppearanceData.scala @@ -23,12 +23,13 @@ import shapeless.{::, HNil} * -player_guid - does nothing? * @param exosuit the type of exo-suit the avatar will be depicted in; * for Black OPs, the agile exo-suit and the reinforced exo-suit are replaced with the Black OPs exo-suits + * @param char_id a unique character reference identification number */ final case class CharacterAppearanceA(app : BasicCharacterData, data : CommonFieldData, exosuit : ExoSuitType.Value, unk5 : Int, - unk6 : Long, + char_id : Long, unk7 : Int, unk8 : Int, unk9 : Int, diff --git a/common/src/main/scala/net/psforever/types/CertificationType.scala b/common/src/main/scala/net/psforever/types/CertificationType.scala index ea4ca3ab5..99afa33c5 100644 --- a/common/src/main/scala/net/psforever/types/CertificationType.scala +++ b/common/src/main/scala/net/psforever/types/CertificationType.scala @@ -3,6 +3,8 @@ package net.psforever.types import net.psforever.packet.PacketHelpers import scodec.codecs._ + +import scala.annotation.tailrec /** * An `Enumeration` of the available certifications.
*
@@ -76,4 +78,59 @@ object CertificationType extends Enumeration { = Value implicit val codec = PacketHelpers.createEnumerationCodec(this, uint8L) + + /** + * Certifications are often stored, in object form, as a 46-member collection. + * Encode a subset of certification values for packet form. + * @see `ChangeSquadMemberRequirementsCertifications` + * @see `changeSquadMemberRequirementsCertificationsCodec` + * @param certs the certifications, as a sequence of values + * @return the certifications, as a single value + */ + def toEncodedLong(certs : Set[CertificationType.Value]) : Long = { + certs + .map{ cert => math.pow(2, cert.id).toLong } + .foldLeft(0L)(_ + _) + } + + /** + * Certifications are often stored, in packet form, as an encoded little-endian `46u` value. + * Decode a representative value into a subset of certification values. + * @see `ChangeSquadMemberRequirementsCertifications` + * @see `changeSquadMemberRequirementsCertificationsCodec` + * @see `fromEncodedLong(Long, Iterable[Long], Set[CertificationType.Value])` + * @param certs the certifications, as a single value + * @return the certifications, as a sequence of values + */ + def fromEncodedLong(certs : Long) : Set[CertificationType.Value] = { + recursiveFromEncodedLong( + certs, + CertificationType.values.map{ cert => math.pow(2, cert.id).toLong }.toSeq.sorted + ) + } + + /** + * Certifications are often stored, in packet form, as an encoded little-endian `46u` value. + * Decode a representative value into a subset of certification values + * by repeatedly finding the partition point of values less than a specific one, + * providing for both the next lowest value (to subtract) and an index (of a certification). + * @see `ChangeSquadMemberRequirementsCertifications` + * @see `changeSquadMemberRequirementsCertificationsCodec` + * @see `fromEncodedLong(Long)` + * @param certs the certifications, as a single value + * @param splitList the available values to partition + * @param out the accumulating certification values; + * defaults to an empty set + * @return the certifications, as a sequence of values + */ + @tailrec + private def recursiveFromEncodedLong(certs : Long, splitList : Iterable[Long], out : Set[CertificationType.Value] = Set.empty) : Set[CertificationType.Value] = { + if(certs == 0 || splitList.isEmpty) { + out + } + else { + val (less, _) = splitList.partition(_ <= certs) + recursiveFromEncodedLong(certs - less.last, less, out ++ Set(CertificationType(less.size - 1))) + } + } } diff --git a/common/src/main/scala/net/psforever/types/PlanetSideEmpire.scala b/common/src/main/scala/net/psforever/types/PlanetSideEmpire.scala index 522cd392a..6494d07d2 100644 --- a/common/src/main/scala/net/psforever/types/PlanetSideEmpire.scala +++ b/common/src/main/scala/net/psforever/types/PlanetSideEmpire.scala @@ -12,4 +12,13 @@ object PlanetSideEmpire extends Enumeration { val TR, NC, VS, NEUTRAL = Value implicit val codec = PacketHelpers.createEnumerationCodec(this, uint2L) + + def apply(id : String) : PlanetSideEmpire.Value = { + values.find(_.toString.equals(id)) match { + case Some(faction) => + faction + case None => + throw new NoSuchElementException(s"can not find an empire associated with $id") + } + } } diff --git a/common/src/main/scala/net/psforever/types/SquadRequestType.scala b/common/src/main/scala/net/psforever/types/SquadRequestType.scala new file mode 100644 index 000000000..1947abe75 --- /dev/null +++ b/common/src/main/scala/net/psforever/types/SquadRequestType.scala @@ -0,0 +1,27 @@ +// Copyright (c) 2019 PSForever +package net.psforever.types + +import net.psforever.packet.PacketHelpers +import scodec.codecs._ + +object SquadRequestType extends Enumeration { + type Type = Value + val + Invite, + ProximityInvite, + Accept, + Reject, + Cancel, + Leave, + Promote, + Disband, + PlatoonInvite, + PlatoonAccept, + PlatoonReject, + PlatoonCancel, + PlatoonLeave, + PlatoonDisband + = Value + + implicit val codec = PacketHelpers.createEnumerationCodec(this, uint4L) +} diff --git a/common/src/main/scala/net/psforever/types/SquadResponseType.scala b/common/src/main/scala/net/psforever/types/SquadResponseType.scala new file mode 100644 index 000000000..84ab57ad4 --- /dev/null +++ b/common/src/main/scala/net/psforever/types/SquadResponseType.scala @@ -0,0 +1,26 @@ +// Copyright (c) 2019 PSForever +package net.psforever.types + +import net.psforever.packet.PacketHelpers +import scodec.codecs._ + +object SquadResponseType extends Enumeration { + type Type = Value + val + Invite, + Unk01, + Accept, + Reject, + Cancel, + Leave, + Disband, + PlatoonInvite, + PlatoonAccept, + PlatoonReject, + PlatoonCancel, + PlatoonLeave, + PlatoonDisband + = Value + + implicit val codec = PacketHelpers.createEnumerationCodec(this, uint4L) +} diff --git a/common/src/main/scala/net/psforever/types/SquadWaypoints.scala b/common/src/main/scala/net/psforever/types/SquadWaypoints.scala new file mode 100644 index 000000000..5a11ea95c --- /dev/null +++ b/common/src/main/scala/net/psforever/types/SquadWaypoints.scala @@ -0,0 +1,18 @@ +// Copyright (c) 2019 PSForever +package net.psforever.types + +import net.psforever.packet.PacketHelpers +import scodec.codecs._ + +object SquadWaypoints extends Enumeration { + type Type = Value + val + One, + Two, + Three, + Four, + ExperienceRally + = Value + + implicit val codec = PacketHelpers.createEnumerationCodec(this, uint8L) +} diff --git a/common/src/main/scala/services/teamwork/SquadService.scala b/common/src/main/scala/services/teamwork/SquadService.scala new file mode 100644 index 000000000..ad717e598 --- /dev/null +++ b/common/src/main/scala/services/teamwork/SquadService.scala @@ -0,0 +1,3033 @@ +// Copyright (c) 2019 PSForever +package services.teamwork + +import akka.actor.{Actor, ActorRef, Terminated} +import net.psforever.objects.{Avatar, LivePlayerList, Player} +import net.psforever.objects.definition.converter.StatConverter +import net.psforever.objects.loadouts.SquadLoadout +import net.psforever.objects.teamwork.{Member, Squad, SquadFeatures} +import net.psforever.objects.zones.Zone +import net.psforever.packet.game._ +import net.psforever.types._ +import services.{GenericEventBus, Service} + +import scala.collection.concurrent.TrieMap +import scala.collection.mutable +import scala.collection.mutable.ListBuffer + +class SquadService extends Actor { + import SquadService._ + + /** + * The current unique squad identifier, to be wrapped in a `PlanetSideGUID` object later. + * The count always starts at 1, even when reset. + * A squad of `PlanetSideGUID(0)` indicates both a nonexistent squad and the default no-squad for clients. + */ + private var sid : Int = 1 + /** + * All squads.
+ * key - squad unique number; value - the squad wrapped around its attributes object + */ + private var squadFeatures : TrieMap[PlanetSideGUID, SquadFeatures] = new TrieMap[PlanetSideGUID, SquadFeatures]() + /** + * The list of squads that each of the factions see for the purposes of keeping track of changes to the list. + * These squads are considered public "listed" squads - + * all the players of a certain faction can see them in the squad list + * and may have limited interaction with their squad definition windows.
+ * key - squad unique number; value - the squad's unique identifier number + */ + private val publishedLists : TrieMap[PlanetSideEmpire.Value, ListBuffer[PlanetSideGUID]] = TrieMap[PlanetSideEmpire.Value, ListBuffer[PlanetSideGUID]]( + PlanetSideEmpire.TR -> ListBuffer.empty, + PlanetSideEmpire.NC -> ListBuffer.empty, + PlanetSideEmpire.VS -> ListBuffer.empty + ) + + /** + * key - a unique character identifier number; value - the squad to which this player is a member + */ + private var memberToSquad : mutable.LongMap[Squad] = mutable.LongMap[Squad]() + /** + * key - a unique character identifier number; value - the active invitation object + */ + private val invites : mutable.LongMap[Invitation] = mutable.LongMap[Invitation]() + /** + * key - a unique character identifier number; value - a list of inactive invitation objects waiting to be resolved + */ + private val queuedInvites : mutable.LongMap[List[Invitation]] = mutable.LongMap[List[Invitation]]() + /** + * The given player has refused participation into this other player's squad.
+ * key - a unique character identifier number; value - a list of unique character identifier numbers + */ + private val refused : mutable.LongMap[List[Long]] = mutable.LongMap[List[Long]]() + /** + * Players who are interested in updated details regarding a certain squad though they may not be a member of the squad.
+ * key - unique character identifier number; value - a squad identifier number + */ + private val continueToMonitorDetails : mutable.LongMap[PlanetSideGUID] = mutable.LongMap[PlanetSideGUID]() + /** + * A placeholder for an absent active invite that has not (yet) been accepted or rejected, + * equal to the then-current active invite. + * Created when removing an active invite. + * Checked when trying to add a new invite (if found, the new invite is queued). + * Cleared when the next queued invite becomes active.
+ * key - unique character identifier number; value, unique character identifier number + */ + private val previousInvites : mutable.LongMap[Invitation] = mutable.LongMap[Invitation]() + + /** + * This is a formal `ActorEventBus` object that is reserved for faction-wide messages and squad-specific messages. + * When the user joins the `SquadService` with a `Service.Join` message + * that includes a confirmed faction affiliation identifier, + * the origin `ActorRef` is added as a subscription. + * Squad channels are produced when a squad is created, + * and are subscribed to as users join the squad, + * and unsubscribed from as users leave the squad.
+ * key - a `PlanetSideEmpire` value; value - `ActorRef` reference
+ * key - a consistent squad channel name; value - `ActorRef` reference + * @see `CloseSquad` + * @see `JoinSquad` + * @see `LeaveSquad` + * @see `Service.Join` + * @see `Service.Leave` + */ + private val SquadEvents = new GenericEventBus[SquadServiceResponse] + /** + * This collection contains the message-sending contact reference for individuals. + * When the user joins the `SquadService` with a `Service.Join` message + * that includes their unique character identifier, + * and the origin `ActorRef` is added as a subscription. + * It is maintained until they disconnect entirely. + * The subscription is anticipated to belong to an instance of `WorldSessionActor`.
+ * key - unique character identifier number; value - `ActorRef` reference for that character + * @see `Service.Join` + */ + private val UserEvents : mutable.LongMap[ActorRef] = mutable.LongMap[ActorRef]() + + private [this] val log = org.log4s.getLogger + + private def debug(msg : String) : Unit = { + log.info(msg) + } + + override def preStart : Unit = { + log.info("Starting...") + } + + override def postStop() : Unit = { + //invitations + invites.clear() + queuedInvites.clear() + previousInvites.clear() + refused.clear() + continueToMonitorDetails.clear() + //squads and members (users) + squadFeatures.foreach { case(_, features) => + CloseSquad(features.Squad) + } + memberToSquad.clear() + publishedLists.clear() + UserEvents.foreach { case(_, actor) => + SquadEvents.unsubscribe(actor) + } + UserEvents.clear() + } + + /** + * Produce the next available unique squad identifier. + * The first number is always 1. + * The greatest possible identifier is 65535 (an unsigned 16-bit integer) + * before it wraps back around to 1. + * @return the current squad unique identifier number + */ + def GetNextSquadId() : PlanetSideGUID = { + val out = sid + val j = sid + 1 + if(j == 65536) { + sid = 1 + } + else { + sid = j + } + PlanetSideGUID(out) + } + + /** + * Set the unique squad identifier back to the start (1) if no squads are active and no players are logged on. + * @return `true`, if the identifier is reset; `false`, otherwise + */ + def TryResetSquadId() : Boolean = { + if(UserEvents.isEmpty && squadFeatures.isEmpty) { + sid = 1 + true + } + else { + false + } + } + + /** + * If a squad exists for an identifier, return that squad. + * @param id the squad unique identifier number + * @return the discovered squad, or `None` + */ + def GetSquad(id : PlanetSideGUID) : Option[Squad] = squadFeatures.get(id) match { + case Some(features) => Some(features.Squad) + case None => None + } + + /** + * If this player is a member of any squad, discover that squad. + * @param player the potential member + * @return the discovered squad, or `None` + */ + def GetParticipatingSquad(player : Player) : Option[Squad] = GetParticipatingSquad(player.CharId) + + /** + * If the player associated with this unique character identifier number is a member of any squad, discover that squad. + * @param charId the potential member identifier + * @return the discovered squad, or `None` + */ + def GetParticipatingSquad(charId : Long) : Option[Squad] = memberToSquad.get(charId) match { + case opt @ Some(_) => + opt + case None => + None + } + + /** + * If this player is a member of any squad, discover that squad. + * @see `GetParticipatingSquad` + * @see `Squad::Leader` + * @param player the potential member + * @param opt an optional squad to check; + * the expectation is that the provided squad is a known participating squad + * @return the discovered squad, or `None` + */ + def GetLeadingSquad(player : Player, opt : Option[Squad]) : Option[Squad] = GetLeadingSquad(player.CharId, opt) + + /** + * If the player associated with this unique character identifier number is the leader of any squad, discover that squad. + * @see `GetParticipatingSquad` + * @see `Squad->Leader` + * @param charId the potential member identifier + * @param opt an optional squad to check; + * the expectation is that the provided squad is a known participating squad + * @return the discovered squad, or `None` + */ + def GetLeadingSquad(charId : Long, opt : Option[Squad]) : Option[Squad] = opt.orElse(GetParticipatingSquad(charId)) match { + case Some(squad) => + if(squad.Leader.CharId == charId) { + Some(squad) + } + else { + None + } + case _ => + None + } + + /** + * Overloaded message-sending operation. + * The `Actor` version wraps around the expected `!` functionality. + * @param to an `ActorRef` which to send the message + * @param msg a message that can be stored in a `SquadServiceResponse` object + */ + def Publish(to : ActorRef, msg : SquadResponse.Response) : Unit = { + Publish(to, msg, Nil) + } + /** + * Overloaded message-sending operation. + * The `Actor` version wraps around the expected `!` functionality. + * @param to an `ActorRef` which to send the message + * @param msg a message that can be stored in a `SquadServiceResponse` object + * @param excluded a group of character identifier numbers who should not receive the message + * (resolved at destination) + */ + def Publish(to : ActorRef, msg : SquadResponse.Response, excluded : Iterable[Long]) : Unit = { + to ! SquadServiceResponse("", excluded, msg) + } + /** + * Overloaded message-sending operation. + * Always publishes on the `SquadEvents` object. + * @param to a faction affiliation used as the channel for the message + * @param msg a message that can be stored in a `SquadServiceResponse` object + */ + def Publish(to : PlanetSideEmpire.Type, msg : SquadResponse.Response) : Unit = { + Publish(to, msg, Nil) + } + /** + * Overloaded message-sending operation. + * Always publishes on the `SquadEvents` object. + * @param to a faction affiliation used as the channel for the message + * @param msg a message that can be stored in a `SquadServiceResponse` object + * @param excluded a group of character identifier numbers who should not receive the message + * (resolved at destination) + */ + def Publish(to : PlanetSideEmpire.Type, msg : SquadResponse.Response, excluded : Iterable[Long]) : Unit = { + SquadEvents.publish(SquadServiceResponse(s"/$to/Squad", excluded, msg)) + } + /** + * Overloaded message-sending operation. + * Strings come in three accepted patterns. + * The first resolves into a faction name, as determined by `PlanetSideEmpire` when transformed into a string. + * The second resolves into a squad's dedicated channel, a name that is formulaic. + * The third resolves as a unique character identifier number. + * @param to a string used as the channel for the message + * @param msg a message that can be stored in a `SquadServiceResponse` object + */ + def Publish(to : String, msg : SquadResponse.Response) : Unit = { + Publish(to, msg, Nil) + } + /** + * Overloaded message-sending operation. + * Strings come in three accepted patterns. + * The first resolves into a faction name, as determined by `PlanetSideEmpire` when transformed into a string. + * The second resolves into a squad's dedicated channel, a name that is formulaic. + * The third resolves as a unique character identifier number. + * @param to a string used as the channel for the message + * @param msg a message that can be stored in a `SquadServiceResponse` object + * @param excluded a group of character identifier numbers who should not receive the message + * (resolved at destination, usually) + */ + def Publish(to : String, msg : SquadResponse.Response, excluded : Iterable[Long]) : Unit = { + to match { + case str if "TRNCVS".indexOf(str) > -1 || str.matches("(TR|NC|VS)-Squad\\d+") => + SquadEvents.publish(SquadServiceResponse(s"/$str/Squad", excluded, msg)) + case str if str.matches("//d+") => + Publish(to.toLong, msg, excluded) + case _ => + log.error(s"Publish(String): subscriber information is an unhandled format - $to") + } + } + /** + * Overloaded message-sending operation. + * Always publishes on the `ActorRef` objects retained by the `UserEvents` object. + * @param to a unique character identifier used as the channel for the message + * @param msg a message that can be stored in a `SquadServiceResponse` object + */ + def Publish(to : Long, msg : SquadResponse.Response) : Unit = { + UserEvents.get(to) match { + case Some(user) => + user ! SquadServiceResponse("", msg) + case None => + log.error(s"Publish(Long): subscriber information can not be found - $to") + } + } + /** + * Overloaded message-sending operation. + * Always publishes on the `ActorRef` objects retained by the `UserEvents` object. + * @param to a unique character identifier used as the channel for the message + * @param msg a message that can be stored in a `SquadServiceResponse` object + * @param excluded a group of character identifier numbers who should not receive the message + */ + def Publish(to : Long, msg : SquadResponse.Response, excluded : Iterable[Long]) : Unit = { + if(!excluded.exists(_ == to)) { + Publish(to, msg) + } + } + /** + * Overloaded message-sending operation. + * No message can be sent using this distinction. + * Log a warning. + * @param to something that was expected to be used as the channel for the message + * but is not handled as such + * @param msg a message that can be stored in a `SquadServiceResponse` object + */ + def Publish[ANY >: Any](to : ANY, msg : SquadResponse.Response) : Unit = { + log.warn(s"Publish(Any): subscriber information is an unhandled format - $to") + } + /** + * Overloaded message-sending operation. + * No message can be sent using this distinction. + * Log a warning. + * @param to something that was expected to be used as the channel for the message + * but is not handled as such + * @param msg a message that can be stored in a `SquadServiceResponse` object + * @param excluded a group of character identifier numbers who should not receive the message + */ + def Publish[ANY >: Any](to : ANY, msg : SquadResponse.Response, excluded : Iterable[Long]) : Unit = { + log.warn(s"Publish(Any): subscriber information is an unhandled format - $to") + } + + def receive : Receive = { + //subscribe to a faction's channel - necessary to receive updates about listed squads + case Service.Join(faction) if "TRNCVS".indexOf(faction) > -1 => + val path = s"/$faction/Squad" + val who = sender() + debug(s"$who has joined $path") + SquadEvents.subscribe(who, path) + + //subscribe to the player's personal channel - necessary for future and previous squad information + case Service.Join(char_id) => + try { + val longCharId = char_id.toLong + val path = s"/$char_id/Squad" + val who = sender() + debug(s"$who has joined $path") + context.watch(who) + UserEvents += longCharId -> who + refused(longCharId) = Nil + } + catch { + case _ : ClassCastException => + log.warn(s"Service.Join: tried $char_id as a unique character identifier, but it could not be casted") + case e : Exception => + log.error(s"Service.Join: unexpected exception using $char_id as data - ${e.getLocalizedMessage}") + e.printStackTrace() + } + + case Service.Leave(Some(char_id)) => LeaveService(char_id, sender) + + case Service.Leave(None) | Service.LeaveAll() => UserEvents find { case(_, subscription) => subscription.path.equals(sender.path)} match { + case Some((to, _)) => + LeaveService(to, sender) + case _ => ; + } + + case Terminated(actorRef) => + context.unwatch(actorRef) + UserEvents find { case(_, subscription) => subscription eq actorRef} match { + case Some((to, _)) => + LeaveService(to, sender) + case _ => ; + } + + case SquadServiceMessage(tplayer, zone, squad_action) => squad_action match { + case SquadAction.InitSquadList() => + Publish( sender, SquadResponse.InitList(PublishedLists(tplayer.Faction)) ) //send initial squad catalog + + case SquadAction.InitCharId() => + val charId = tplayer.CharId + memberToSquad.get(charId) match { + case None => ; + case Some(squad) => + val guid = squad.GUID + val toChannel = s"/${squadFeatures(guid).ToChannel}/Squad" + val indices = squad.Membership.zipWithIndex.collect({ case (member, index) if member.CharId != 0 => index }).toList + Publish(charId, SquadResponse.AssociateWithSquad(guid)) + Publish(charId, SquadResponse.Join(squad, indices, toChannel)) + InitSquadDetail(guid, Seq(charId), squad) + InitWaypoints(charId, guid) + } + + case SquadAction.Membership(SquadRequestType.Invite, invitingPlayer, Some(_invitedPlayer), invitedName, _) => + //this is just busy work; for actual joining operations, see SquadRequestType.Accept + (if(invitedName.nonEmpty) { + //validate player with name exists + LivePlayerList.WorldPopulation({ case (_, a : Avatar) => a.name.equalsIgnoreCase(invitedName) && a.faction == tplayer.Faction }).headOption match { + case Some(player) => UserEvents.keys.find(_ == player.CharId) + case None => None + } + } + else { + //validate player with id exists + LivePlayerList.WorldPopulation({ case (_, a : Avatar) => a.CharId == _invitedPlayer && a.faction == tplayer.Faction }).headOption match { + case Some(player) => Some(_invitedPlayer) + case None => None + } + }) match { + case Some(invitedPlayer) if invitingPlayer != invitedPlayer => + (memberToSquad.get(invitingPlayer), memberToSquad.get(invitedPlayer)) match { + case (Some(squad1), Some(squad2)) if squad1.GUID == squad2.GUID => + //both players are in the same squad; no need to do anything + + case (Some(squad1), Some(squad2)) if squad1.Leader.CharId == invitingPlayer && squad2.Leader.CharId == invitedPlayer && + squad1.Size > 1 && squad2.Size > 1 => + //we might do some platoon chicanery with this case later + //TODO platoons + + case (Some(squad1), Some(squad2)) if squad2.Size == 1 => + //both players belong to squads, but the invitedPlayer's squad (squad2) is underutilized by comparison + //treat the same as "the classic situation" using squad1 + if(!Refused(invitedPlayer).contains(invitingPlayer)) { + val charId = tplayer.CharId + AddInviteAndRespond( + invitedPlayer, + VacancyInvite(charId, tplayer.Name, squad1.GUID), + charId, + tplayer.Name + ) + } + + case (Some(squad1), Some(squad2)) if squad1.Size == 1 => + //both players belong to squads, but the invitingPlayer's squad is underutilized by comparison + //treat the same as "indirection ..." using squad2 + val leader = squad2.Leader.CharId + if(Refused(invitingPlayer).contains(invitedPlayer)) { + debug(s"$invitedPlayer repeated a previous refusal to $invitingPlayer's invitation offer") + } + else if(Refused(invitingPlayer).contains(leader)) { + debug(s"$invitedPlayer repeated a previous refusal to $leader's invitation offer") + } + else { + AddInviteAndRespond( + leader, + IndirectInvite(tplayer, squad2.GUID), + invitingPlayer, + tplayer.Name + ) + } + + case (Some(squad), None) => + //the classic situation + if(!Refused(invitedPlayer).contains(invitingPlayer)) { + AddInviteAndRespond( + invitedPlayer, + VacancyInvite(tplayer.CharId, tplayer.Name, squad.GUID), + invitingPlayer, + tplayer.Name + ) + } + else { + debug(s"$invitedPlayer repeated a previous refusal to $invitingPlayer's invitation offer") + } + + case (None, Some(squad)) => + //indirection; we're trying to invite ourselves to someone else's squad + val leader = squad.Leader.CharId + if(Refused(invitingPlayer).contains(invitedPlayer)) { + debug(s"$invitedPlayer repeated a previous refusal to $invitingPlayer's invitation offer") + } + else if(Refused(invitingPlayer).contains(leader)) { + debug(s"$invitedPlayer repeated a previous refusal to $leader's invitation offer") + } + else { + AddInviteAndRespond( + squad.Leader.CharId, + IndirectInvite(tplayer, squad.GUID), + invitingPlayer, + tplayer.Name + ) + } + + case (None, None) => + //neither the invited player nor the inviting player belong to any squad + if(Refused(invitingPlayer).contains(invitedPlayer)) { + debug(s"$invitedPlayer repeated a previous refusal to $invitingPlayer's invitation offer") + } + else if(Refused(invitedPlayer).contains(invitingPlayer)) { + debug(s"$invitingPlayer repeated a previous refusal to $invitingPlayer's invitation offer") + } + else { + AddInviteAndRespond( + invitedPlayer, + SpontaneousInvite(tplayer), + invitingPlayer, + tplayer.Name + ) + } + + case _ => ; + } + case _ => ; + } + + case SquadAction.Membership(SquadRequestType.ProximityInvite, invitingPlayer, _, _, _) => + GetLeadingSquad(invitingPlayer, None) match { + case Some(squad) => + val sguid = squad.GUID + val features = squadFeatures(sguid) + features.SearchForRole match { + case Some(-1) => + //we've already issued a proximity invitation; no need to do another + debug("ProximityInvite: wait for existing proximity invitations to clear") + case _ => + val outstandingActiveInvites = features.SearchForRole match { + case Some(pos) => + RemoveQueuedInvitesForSquadAndPosition(sguid, pos) + invites.collect { case(charId, LookingForSquadRoleInvite(_,_, squad_guid, role)) if squad_guid == sguid && role == pos => charId } + case None => + List.empty[Long] + } + val faction = squad.Faction + val center = tplayer.Position + val excusedInvites = features.Refuse + //positions that can be recruited to + val positions = squad.Membership.zipWithIndex + .collect { case(member, index) if member.CharId == 0 && squad.Availability(index) => member } + /* + players who are: + - the same faction as the squad + - have Looking For Squad enabled + - do not currently belong to a squad + - are denied the opportunity to be invited + - are a certain distance from the squad leader (n < 25m) + */ + (zone.LivePlayers + .collect { case player + if player.Faction == faction && player.LFS && + (memberToSquad.get(player.CharId).isEmpty || memberToSquad(player.CharId).Size == 1) && + !excusedInvites.contains(player.CharId) && Refused(player.CharId).contains(squad.Leader.CharId) && + Vector3.DistanceSquared(player.Position, center) < 625f && + { + positions + .map { role => + val requirementsToMeet = role.Requirements + requirementsToMeet.intersect(player.Certifications) == requirementsToMeet + } + .foldLeft(false)(_ || _) + } => player.CharId + } + .partition { charId => outstandingActiveInvites.exists(_ == charId) } match { + case (Nil, Nil) => + //no one found + outstandingActiveInvites foreach RemoveInvite + features.ProxyInvites = Nil + None + case (outstandingPlayerList, invitedPlayerList) => + //players who were actively invited for the previous position and are eligible for the new position + features.SearchForRole = Some(-1) + outstandingPlayerList.foreach { charId => + val bid = invites(charId).asInstanceOf[LookingForSquadRoleInvite] + invites(charId) = ProximityInvite(bid.char_id, bid.name, sguid) + } + //players who were actively invited for the previous position but are ineligible for the new position + (features.ProxyInvites filterNot (outstandingPlayerList contains)) foreach RemoveInvite + features.ProxyInvites = outstandingPlayerList ++ invitedPlayerList + Some(invitedPlayerList) + }) match { + //add invitations for position in squad + case Some(invitedPlayers) => + val invitingPlayer = tplayer.CharId + val name = tplayer.Name + invitedPlayers.foreach { invitedPlayer => + AddInviteAndRespond( + invitedPlayer, + ProximityInvite(invitingPlayer, name, sguid), + invitingPlayer, + name + ) + } + case None => ; + } + } + + case None => + } + + case SquadAction.Membership(SquadRequestType.Accept, invitedPlayer, _, _, _) => + val acceptedInvite = RemoveInvite(invitedPlayer) + acceptedInvite match { + case Some(RequestRole(petitioner, guid, position)) if EnsureEmptySquad(petitioner.CharId) && squadFeatures.get(guid).nonEmpty => + //player requested to join a squad's specific position + //invitedPlayer is actually the squad leader; petitioner is the actual "invitedPlayer" + val features = squadFeatures(guid) + JoinSquad(petitioner, features.Squad, position) + RemoveInvitesForSquadAndPosition(guid, position) + + case Some(IndirectInvite(recruit, guid)) if EnsureEmptySquad(recruit.CharId) => + //tplayer / invitedPlayer is actually the squad leader + val recruitCharId = recruit.CharId + HandleVacancyInvite(guid, recruitCharId, invitedPlayer, recruit) match { + case Some((squad, line)) => + Publish(invitedPlayer, SquadResponse.Membership(SquadResponseType.Accept, 0, 0, invitedPlayer, Some(recruitCharId), recruit.Name, true, Some(None))) + JoinSquad(recruit, squad, line) + RemoveInvitesForSquadAndPosition(squad.GUID, line) + //since we are the squad leader, we do not want to brush off our queued squad invite tasks + case _ => ; + } + + case Some(VacancyInvite(invitingPlayer, _, guid)) if EnsureEmptySquad(invitedPlayer) => + //accepted an invitation to join an existing squad + HandleVacancyInvite(guid, invitedPlayer, invitingPlayer, tplayer) match { + case Some((squad, line)) => + Publish(invitingPlayer, SquadResponse.Membership(SquadResponseType.Accept, 0, 0, invitingPlayer, Some(invitedPlayer), tplayer.Name, false, Some(None))) + Publish(invitedPlayer, SquadResponse.Membership(SquadResponseType.Accept, 0, 0, invitedPlayer, Some(invitingPlayer), "", true, Some(None))) + JoinSquad(tplayer, squad, line) + RemoveQueuedInvites(invitedPlayer) //TODO deal with these somehow + RemoveInvitesForSquadAndPosition(squad.GUID, line) + case _ => ; + } + + case Some(SpontaneousInvite(invitingPlayer)) if EnsureEmptySquad(invitedPlayer) => + //originally, we were invited by someone into a new squad they would form + val invitingPlayerCharId = invitingPlayer.CharId + (GetParticipatingSquad(invitingPlayer) match { + case Some(participating) => + //invitingPlayer became part of a squad while invited player was answering the original summons + Some(participating) + case _ => + //generate a new squad, with invitingPlayer as the leader + val squad = StartSquad(invitingPlayer) + squad.Task = s"${invitingPlayer.Name}'s Squad" + Publish(invitingPlayerCharId, SquadResponse.AssociateWithSquad(squad.GUID)) + Some(squad) + }) match { + case Some(squad) => + HandleVacancyInvite(squad, tplayer.CharId, invitingPlayerCharId, tplayer) match { + case Some((_, line)) => + Publish(invitedPlayer, SquadResponse.Membership(SquadResponseType.Accept, 0, 0, invitedPlayer, Some(invitingPlayerCharId), "", true, Some(None))) + Publish(invitingPlayerCharId, SquadResponse.Membership(SquadResponseType.Accept, 0, 0, invitingPlayerCharId, Some(invitedPlayer), tplayer.Name, false, Some(None))) + JoinSquad(tplayer, squad, line) + RemoveQueuedInvites(tplayer.CharId) //TODO deal with these somehow + case _ => ; + } + case _ => ; + } + + case Some(LookingForSquadRoleInvite(invitingPlayer, name, guid, position)) if EnsureEmptySquad(invitedPlayer) => + squadFeatures.get(guid) match { + case Some(features) if JoinSquad(tplayer, features.Squad, position) => + //join this squad + Publish(invitedPlayer, SquadResponse.Membership(SquadResponseType.Accept, 0, 0, invitedPlayer, Some(invitingPlayer), "", true, Some(None))) + Publish(invitingPlayer, SquadResponse.Membership(SquadResponseType.Accept, 0, 0, invitingPlayer, Some(invitedPlayer), tplayer.Name, false, Some(None))) + RemoveQueuedInvites(tplayer.CharId) + features.ProxyInvites = Nil + features.SearchForRole = None + RemoveInvitesForSquadAndPosition(guid, position) + + case Some(features) => + //can not join squad; position is unavailable or other reasons block action + features.ProxyInvites = features.ProxyInvites.filterNot(_ == invitedPlayer) + + case _ => + //squad no longer exists? + } + + case Some(ProximityInvite(invitingPlayer, _, guid)) if EnsureEmptySquad(invitedPlayer) => + squadFeatures.get(guid) match { + case Some(features) => + val squad = features.Squad + if(squad.Size < squad.Capacity) { + val positions = (for { + (member, index) <- squad.Membership.zipWithIndex + if squad.isAvailable(index, tplayer.Certifications) + } yield (index, member.Requirements.size)) + .toList + .sortBy({ case (_, reqs) => reqs }) + ((positions.headOption, positions.lastOption) match { + case (Some((first, size1)), Some((_, size2))) if size1 == size2 => Some(first) //join the first available position + case (Some(_), Some((last, _))) => Some(last) //join the most demanding position + case _ => None + }) match { + case Some(position) if JoinSquad(tplayer, squad, position) => + //join this squad + Publish(invitedPlayer, SquadResponse.Membership(SquadResponseType.Accept, 0, 0, invitedPlayer, Some(invitingPlayer), "", true, Some(None))) + Publish(invitingPlayer, SquadResponse.Membership(SquadResponseType.Accept, 0, 0, invitingPlayer, Some(invitedPlayer), tplayer.Name, false, Some(None))) + RemoveQueuedInvites(invitedPlayer) + features.ProxyInvites = features.ProxyInvites.filterNot(_ == invitedPlayer) + case _ => + } + } + if(features.ProxyInvites.isEmpty) { + //all invitations exhausted; this invitation period is concluded + features.SearchForRole = None + } + else if(squad.Size == squad.Capacity) { + //all available squad positions filled; terminate all remaining invitations + RemoveProximityInvites(guid) + RemoveAllInvitesToSquad(guid) + //RemoveAllInvitesWithPlayer(invitingPlayer) + } + + case _ => + //squad no longer exists? + } + + case _ => + //the invite either timed-out or was withdrawn or is now invalid + (previousInvites.get(invitedPlayer) match { + case Some(SpontaneousInvite(leader)) => (leader.CharId, leader.Name) + case Some(VacancyInvite(charId, name, _)) => (charId, name) + case Some(ProximityInvite(charId, name, _)) => (charId, name) + case Some(LookingForSquadRoleInvite(charId, name, _, _)) => (charId, name) + case _ => (0L, "") + }) match { + case (0L, "") => ; + case (charId, name) => + Publish(charId, SquadResponse.Membership(SquadResponseType.Cancel, 0, 0, charId, Some(0L), name, false, Some(None))) + } + } + NextInviteAndRespond(invitedPlayer) + + case SquadAction.Membership(SquadRequestType.Leave, actingPlayer, _leavingPlayer, name, _) => + GetParticipatingSquad(actingPlayer) match { + case Some(squad) => + val leader = squad.Leader.CharId + (if(name.nonEmpty) { + //validate player with name + LivePlayerList.WorldPopulation({ case (_, a : Avatar) => a.name.equalsIgnoreCase(name) }).headOption match { + case Some(a) => UserEvents.keys.find(_ == a.CharId) + case None => None + } + } + else { + //validate player with id + _leavingPlayer match { + case Some(id) => UserEvents.keys.find(_ == id) + case None => None + } + }) match { + case out @ Some(leavingPlayer) if GetParticipatingSquad(leavingPlayer).contains(squad) => //kicked player must be in the same squad + if(actingPlayer == leader) { + if(leavingPlayer == leader || squad.Size == 2) { + //squad leader is leaving his own squad, so it will be disbanded + //OR squad is only composed of two people, so it will be closed-out when one of them leaves + DisbandSquad(squad) + } + else { + //kicked by the squad leader + Publish(leavingPlayer, SquadResponse.Membership(SquadResponseType.Leave, 0, 0, leavingPlayer, Some(leader), tplayer.Name, false, Some(None))) + Publish(leader, SquadResponse.Membership(SquadResponseType.Leave, 0, 0, leader, Some(leavingPlayer), "", true, Some(None))) + squadFeatures(squad.GUID).Refuse = leavingPlayer + LeaveSquad(leavingPlayer, squad) + } + } + else if(leavingPlayer == actingPlayer) { + if(squad.Size == 2) { + //squad is only composed of two people, so it will be closed-out when one of them leaves + DisbandSquad(squad) + } + else { + //leaving the squad of own accord + LeaveSquad(actingPlayer, squad) + } + } + + case _ => ; + } + case _ => ; + } + + case SquadAction.Membership(SquadRequestType.Reject, rejectingPlayer, _, _, _) => + val rejectedBid = RemoveInvite(rejectingPlayer) + //(A, B) -> person who made the rejection, person who was rejected + (rejectedBid match { + case Some(SpontaneousInvite(leader)) => + //rejectingPlayer is the would-be squad member; the squad leader's request was rejected + val invitingPlayerCharId = leader.CharId + Refused(rejectingPlayer, invitingPlayerCharId) + (Some(rejectingPlayer), Some(invitingPlayerCharId)) + + case Some(VacancyInvite(leader, _, guid)) + if squadFeatures.get(guid).nonEmpty && squadFeatures(guid).Squad.Leader.CharId != rejectingPlayer => + //rejectingPlayer is the would-be squad member; the squad leader's request was rejected + Refused(rejectingPlayer, leader) + (Some(rejectingPlayer), Some(leader)) + + case Some(ProximityInvite(_, _, guid)) + if squadFeatures.get(guid).nonEmpty && squadFeatures(guid).Squad.Leader.CharId != rejectingPlayer => + //rejectingPlayer is the would-be squad member; the squad leader's request was rejected + val features = squadFeatures(guid) + features.Refuse = rejectingPlayer //do not bother this player anymore + if((features.ProxyInvites = features.ProxyInvites.filterNot(_ == rejectingPlayer)).isEmpty) { + features.SearchForRole = None + } + (None, None) + + case Some(LookingForSquadRoleInvite(leader, _, guid, _)) + if squadFeatures.get(guid).nonEmpty && squadFeatures(guid).Squad.Leader.CharId != rejectingPlayer => + //rejectingPlayer is the would-be squad member; the squad leader's request was rejected + Refused(rejectingPlayer, leader) + val features = squadFeatures(guid) + features.Refuse = rejectingPlayer + if((features.ProxyInvites = features.ProxyInvites.filterNot(_ == rejectingPlayer)).isEmpty) { + features.SearchForRole = None + } + (None, None) + + case Some(RequestRole(_, guid, _)) + if squadFeatures.get(guid).nonEmpty && squadFeatures(guid).Squad.Leader.CharId == rejectingPlayer => + //rejectingPlayer is the squad leader; candidate is the would-be squad member who was rejected + val features = squadFeatures(guid) + features.Refuse = rejectingPlayer + (Some(rejectingPlayer), None) + + case _ => ; + (None, None) + }) match { + case (Some(rejected), Some(invited)) => + Publish(rejected, SquadResponse.Membership(SquadResponseType.Reject, 0, 0, rejected, Some(invited), "", true, Some(None))) + Publish(invited, SquadResponse.Membership(SquadResponseType.Reject, 0, 0, invited, Some(rejected), tplayer.Name, false, Some(None))) + case (Some(rejected), None) => + Publish(rejected, SquadResponse.Membership(SquadResponseType.Reject, 0, 0, rejected, Some(rejected), "", true, Some(None))) + case _ => ; + } + NextInviteAndRespond(rejectingPlayer) + + case SquadAction.Membership(SquadRequestType.Disband, char_id, _, _, _) => + GetLeadingSquad(char_id, None) match { + case Some(squad) => + DisbandSquad(squad) + case None => ; + } + + case SquadAction.Membership(SquadRequestType.Cancel, cancellingPlayer, _, _, _) => + //get rid of SpontaneousInvite objects and VacancyInvite objects + invites.collect { + case (id, invite : SpontaneousInvite) if invite.InviterCharId == cancellingPlayer => + RemoveInvite(id) + case (id, invite : VacancyInvite) if invite.InviterCharId == cancellingPlayer => + RemoveInvite(id) + case (id, invite : LookingForSquadRoleInvite) if invite.InviterCharId == cancellingPlayer => + RemoveInvite(id) + } + queuedInvites.foreach { case (id : Long, inviteList) => + val inList = inviteList.filterNot { + case invite : SpontaneousInvite if invite.InviterCharId == cancellingPlayer => true + case invite : VacancyInvite if invite.InviterCharId == cancellingPlayer => true + case invite : LookingForSquadRoleInvite if invite.InviterCharId == cancellingPlayer => true + case _ => false + } + if(inList.isEmpty) { + queuedInvites.remove(id) + } + else { + queuedInvites(id) = inList + } + } + //get rid of ProximityInvite objects + RemoveProximityInvites(cancellingPlayer) + + case SquadAction.Membership(SquadRequestType.Promote, promotingPlayer, Some(_promotedPlayer), promotedName, _) => + val promotedPlayer = (if(promotedName.nonEmpty) { + //validate player with name exists + LivePlayerList.WorldPopulation({ case (_, a : Avatar) => a.name == promotedName }).headOption match { + case Some(player) => UserEvents.keys.find(_ == player.CharId) + case None => Some(_promotedPlayer) + } + } + else { + Some(_promotedPlayer) + }) match { + case Some(player) => player + case None => -1L + } + (GetLeadingSquad(promotingPlayer, None), GetParticipatingSquad(promotedPlayer)) match { + case (Some(squad), Some(squad2)) if squad.GUID == squad2.GUID => + val membership = squad.Membership.filter { _member => _member.CharId > 0 } + val leader = squad.Leader + val (member, index) = membership.zipWithIndex.find { case (_member, _) => _member.CharId == promotedPlayer }.get + val features = squadFeatures(squad.GUID) + SwapMemberPosition(leader, member) + //move around invites so that the proper squad leader deals with them + val leaderInvite = invites.remove(promotingPlayer) + val leaderQueuedInvites = queuedInvites.remove(promotingPlayer).toList.flatten + invites.get(promotedPlayer).orElse(previousInvites.get(promotedPlayer)) match { + case Some(_) => + //the promoted player has an active invite; queue these + queuedInvites += promotedPlayer -> (leaderInvite.toList ++ leaderQueuedInvites ++ queuedInvites.remove(promotedPlayer).toList.flatten) + case None if leaderInvite.nonEmpty => + //no active invite for the promoted player, but the leader had an active invite; trade the queued invites + val invitation = leaderInvite.get + AddInviteAndRespond(promotedPlayer, invitation, invitation.InviterCharId, invitation.InviterName) + queuedInvites += promotedPlayer -> (leaderQueuedInvites ++ queuedInvites.remove(promotedPlayer).toList.flatten) + case None => + //no active invites for anyone; assign the first queued invite from the promoting player, if available, and queue the rest + leaderQueuedInvites match { + case Nil => ; + case x :: xs => + AddInviteAndRespond(promotedPlayer, x, x.InviterCharId, x.InviterName) + queuedInvites += promotedPlayer -> (xs ++ queuedInvites.remove(promotedPlayer).toList.flatten) + } + } + debug(s"Promoting player ${leader.Name} to be the leader of ${squad.Task}") + Publish(features.ToChannel, SquadResponse.PromoteMember(squad, promotedPlayer, index, 0)) + if(features.Listed) { + Publish(promotingPlayer, SquadResponse.SetListSquad(PlanetSideGUID(0))) + Publish(promotedPlayer, SquadResponse.SetListSquad(squad.GUID)) + } + UpdateSquadListWhenListed( + features, + SquadInfo().Leader(leader.Name) + ) + UpdateSquadDetail( + squad.GUID, + SquadDetail() + .LeaderCharId(leader.CharId) + .Field3(value = 0L) + .LeaderName(leader.Name) + .Members(List( + SquadPositionEntry(0, SquadPositionDetail().CharId(leader.CharId).Name(leader.Name)), + SquadPositionEntry(index, SquadPositionDetail().CharId(member.CharId).Name(member.Name)) + )) + ) + case _ => ; + } + + case SquadAction.Membership(event, _, _, _, _) => + debug(s"SquadAction.Membership: $event is not yet supported") + + case SquadAction.Waypoint(_, wtype, _, info) => + val playerCharId = tplayer.CharId + (GetLeadingSquad(tplayer, None) match { + case Some(squad) => + info match { + case Some(winfo) => + (Some(squad), AddWaypoint(squad.GUID, wtype, winfo)) + case _ => + RemoveWaypoint(squad.GUID, wtype) + (Some(squad), None) + } + case _ => (None, None) + }) match { + case (Some(squad), Some(_)) => + //waypoint added or updated + Publish( + s"${squadFeatures(squad.GUID).ToChannel}", + SquadResponse.WaypointEvent(WaypointEventAction.Add, playerCharId, wtype, None, info, 1), + Seq(tplayer.CharId) + ) + case (Some(squad), None) => + //waypoint removed + Publish( + s"${squadFeatures(squad.GUID).ToChannel}", + SquadResponse.WaypointEvent(WaypointEventAction.Remove, playerCharId, wtype, None, None, 0), + Seq(tplayer.CharId) + ) + + case msg => + log.warn(s"Unsupported squad waypoint behavior: $msg") + } + + case SquadAction.Definition(guid, line, action) => + import net.psforever.packet.game.SquadAction._ + val pSquadOpt = GetParticipatingSquad(tplayer) + val lSquadOpt = GetLeadingSquad(tplayer, pSquadOpt) + //the following actions can only be performed by a squad's leader + action match { + case SaveSquadFavorite() => + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) + if(squad.Task.nonEmpty && squad.ZoneId > 0) { + tplayer.SquadLoadouts.SaveLoadout(squad, squad.Task, line) + Publish(sender, SquadResponse.ListSquadFavorite(line, squad.Task)) + } + + case LoadSquadFavorite() => + if(pSquadOpt.isEmpty || pSquadOpt == lSquadOpt) { + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) + tplayer.SquadLoadouts.LoadLoadout(line) match { + case Some(loadout : SquadLoadout) if squad.Size == 1 => + SquadService.LoadSquadDefinition(squad, loadout) + UpdateSquadListWhenListed(squadFeatures(squad.GUID), SquadService.SquadList.Publish(squad)) + Publish(sender, SquadResponse.AssociateWithSquad(PlanetSideGUID(0))) + InitSquadDetail(PlanetSideGUID(0), Seq(tplayer.CharId), squad) + UpdateSquadDetail(squad) + Publish(sender, SquadResponse.AssociateWithSquad(squad.GUID)) + case _ => + } + } + + case DeleteSquadFavorite() => + tplayer.SquadLoadouts.DeleteLoadout(line) + Publish(sender, SquadResponse.ListSquadFavorite(line, "")) + + case ChangeSquadPurpose(purpose) => + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) + squad.Task = purpose + UpdateSquadListWhenListed(squadFeatures(squad.GUID), SquadInfo().Task(purpose)) + UpdateSquadDetail(squad.GUID, SquadDetail().Task(purpose)) + + case ChangeSquadZone(zone_id) => + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) + squad.ZoneId = zone_id.zoneId.toInt + UpdateSquadListWhenListed(squadFeatures(squad.GUID), SquadInfo().ZoneId(zone_id)) + InitialAssociation(squad) + Publish(sender, SquadResponse.Detail(squad.GUID, SquadService.Detail.Publish(squad))) + UpdateSquadDetail( + squad.GUID, + squad.GUID, + Seq(squad.Leader.CharId), + SquadDetail().ZoneId(zone_id) + ) + + case CloseSquadMemberPosition(position) => + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) + squad.Availability.lift(position) match { + case Some(true) if position > 0 => //do not close squad leader position; undefined behavior + squad.Availability.update(position, false) + val memberPosition = squad.Membership(position) + if(memberPosition.CharId > 0) { + LeaveSquad(memberPosition.CharId, squad) + } + UpdateSquadListWhenListed(squadFeatures(squad.GUID), SquadInfo().Capacity(squad.Capacity)) + UpdateSquadDetail( + squad.GUID, + SquadDetail().Members(List(SquadPositionEntry(position, SquadPositionDetail.Closed))) + ) + case Some(false) | None => ; + } + + case AddSquadMemberPosition(position) => + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) + squad.Availability.lift(position) match { + case Some(false) => + squad.Availability.update(position, true) + UpdateSquadListWhenListed(squadFeatures(squad.GUID), SquadInfo().Capacity(squad.Capacity)) + UpdateSquadDetail( + squad.GUID, + SquadDetail().Members(List(SquadPositionEntry(position, SquadPositionDetail.Open))) + ) + case Some(true) | None => ; + } + + case ChangeSquadMemberRequirementsRole(position, role) => + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) + squad.Availability.lift(position) match { + case Some(true) => + squad.Membership(position).Role = role + UpdateSquadDetail( + squad.GUID, + SquadDetail().Members(List(SquadPositionEntry(position, SquadPositionDetail().Role(role)))) + ) + case Some(false) | None => ; + } + + case ChangeSquadMemberRequirementsDetailedOrders(position, orders) => + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) + squad.Availability.lift(position) match { + case Some(true) => + squad.Membership(position).Orders = orders + UpdateSquadDetail( + squad.GUID, + SquadDetail().Members(List(SquadPositionEntry(position, SquadPositionDetail().DetailedOrders(orders)))) + ) + case Some(false) | None => ; + } + + case ChangeSquadMemberRequirementsCertifications(position, certs) => + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) + squad.Availability.lift(position) match { + case Some(true) => + squad.Membership(position).Requirements = certs + UpdateSquadDetail( + squad.GUID, + SquadDetail().Members(List(SquadPositionEntry(position, SquadPositionDetail().Requirements(certs)))) + ) + case Some(false) | None => ; + } + + case LocationFollowsSquadLead(state) => + val features = squadFeatures(lSquadOpt.getOrElse(StartSquad(tplayer)).GUID) + features.LocationFollowsSquadLead = state + + case AutoApproveInvitationRequests(state) => + val features = squadFeatures(lSquadOpt.getOrElse(StartSquad(tplayer)).GUID) + features.AutoApproveInvitationRequests = state + if(state) { + //allowed auto-approval - resolve the requests (only) + val charId = tplayer.CharId + val (requests, others) = (invites.get(charId).toList ++ queuedInvites.get(charId).toList) + .partition({ case _ : RequestRole => true}) + invites.remove(charId) + queuedInvites.remove(charId) + previousInvites.remove(charId) + requests.foreach { + case request : RequestRole => + JoinSquad(request.player, features.Squad, request.position) + case _ => ; + } + others.collect { case invite : Invitation => invite } match { + case Nil => ; + case x :: Nil => + AddInviteAndRespond(charId, x, x.InviterCharId, x.InviterName) + case x :: xs => + AddInviteAndRespond(charId, x, x.InviterCharId, x.InviterName) + queuedInvites += charId -> xs + } + } + + case FindLfsSoldiersForRole(position) => + lSquadOpt match { + case Some(squad) => + val sguid = squad.GUID + val features = squadFeatures(sguid) + features.SearchForRole match { + case Some(-1) => + //a proximity invitation has not yet cleared; nothing will be gained by trying to invite for a specific role + debug("FindLfsSoldiersForRole: waiting for proximity invitations to clear") + case _ => + //either no role has ever been recruited, or some other role has been recruited + //normal LFS recruitment for the given position + val excusedInvites = features.Refuse + val faction = squad.Faction + val requirementsToMeet = squad.Membership(position).Requirements + val outstandingActiveInvites = features.SearchForRole match { + case Some(pos) => + RemoveQueuedInvitesForSquadAndPosition(sguid, pos) + invites.collect { case(charId, LookingForSquadRoleInvite(_,_, squad_guid, role)) if squad_guid == sguid && role == pos => charId } + case None => + List.empty[Long] + } + features.SearchForRole = position + //this will update the role entry in the GUI to visually indicate being searched for; only one will be displayed at a time + Publish( + tplayer.CharId, + SquadResponse.Detail( + sguid, + SquadDetail().Members(List(SquadPositionEntry(position, SquadPositionDetail().CharId(char_id = 0L).Name(name = "")))) + ) + ) + //collect all players that are eligible for invitation to the new position + //divide into players with an active invite (A) and players with a queued invite (B) + //further filter (A) into players whose invitation is renewed (A1) and new invitations (A2) + //TODO only checks the leader's current zone; should check all zones + (zone.LivePlayers + .collect { case player + if !excusedInvites.contains(player.CharId) && + faction == player.Faction && player.LFS && memberToSquad.get(player.CharId).isEmpty && + requirementsToMeet.intersect(player.Certifications) == requirementsToMeet => + player.CharId + } + .partition { charId => outstandingActiveInvites.exists(charId == _) } match { + case (Nil, Nil) => + outstandingActiveInvites foreach RemoveInvite + features.ProxyInvites = Nil + //TODO cancel the LFS search from the server so that the client updates properly; how? + None + case (outstandingPlayerList, invitedPlayerList) => + //players who were actively invited for the previous position and are eligible for the new position + outstandingPlayerList.foreach { charId => + val bid = invites(charId).asInstanceOf[LookingForSquadRoleInvite] + invites(charId) = LookingForSquadRoleInvite(bid.char_id, bid.name, sguid, position) + } + //players who were actively invited for the previous position but are ineligible for the new position + (features.ProxyInvites filterNot (outstandingPlayerList contains)) foreach RemoveInvite + features.ProxyInvites = outstandingPlayerList ++ invitedPlayerList + Some(invitedPlayerList) + }) match { + //add invitations for position in squad + case Some(invitedPlayers) => + val invitingPlayer = tplayer.CharId + val name = tplayer.Name + invitedPlayers.foreach { invitedPlayer => + AddInviteAndRespond( + invitedPlayer, + LookingForSquadRoleInvite(invitingPlayer, name, sguid, position), + invitingPlayer, + name + ) + } + case None => ; + } + } + + case _ => ; + } + + case CancelFind() => + lSquadOpt match { + case Some(squad) => + val sguid = squad.GUID + val position = squadFeatures(sguid).SearchForRole + squadFeatures(sguid).SearchForRole = None + //remove active invites + invites.filter { + case (_, LookingForSquadRoleInvite(_, _, _guid, pos)) => _guid == sguid && position.contains(pos) + case _ => false + } + .keys.foreach { charId => + RemoveInvite(charId) + } + //remove queued invites + queuedInvites.foreach { case (charId, queue) => + val filtered = queue.filterNot { + case LookingForSquadRoleInvite(_, _, _guid, _) => _guid == sguid + case _ => false + } + queuedInvites += charId -> filtered + if(filtered.isEmpty) { + queuedInvites.remove(charId) + } + } + //remove yet-to-be invitedPlayers + squadFeatures(sguid).ProxyInvites = Nil + case _ => ; + } + + case RequestListSquad() => + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) + val features = squadFeatures(squad.GUID) + if(!features.Listed && squad.Task.nonEmpty && squad.ZoneId > 0) { + features.Listed = true + InitialAssociation(squad) + Publish(sender, SquadResponse.SetListSquad(squad.GUID)) + UpdateSquadList(squad, None) + } + + case StopListSquad() => + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) + val features = squadFeatures(squad.GUID) + if(features.Listed) { + features.Listed = false + Publish(sender, SquadResponse.SetListSquad(PlanetSideGUID(0))) + UpdateSquadList(squad, None) + } + + case ResetAll() => + lSquadOpt match { + case Some(squad) if squad.Size > 1 => + val guid = squad.GUID + squad.Task = "" + squad.ZoneId = None + squad.Availability.indices.foreach { i => + squad.Availability.update(i, true) + } + squad.Membership.foreach(position => { + position.Role = "" + position.Orders = "" + position.Requirements = Set() + }) + val features = squadFeatures(squad.GUID) + features.LocationFollowsSquadLead = true + features.AutoApproveInvitationRequests = true + if(features.Listed) { + //unlist the squad + features.Listed = false + Publish(features.ToChannel, SquadResponse.SetListSquad(PlanetSideGUID(0))) + UpdateSquadList(squad, None) + } + UpdateSquadDetail(squad) + InitialAssociation(squad) + squadFeatures(guid).InitialAssociation = true + case Some(squad) => + //underutilized squad; just close it out + CloseSquad(squad) + case _ => ; + } + + case _ => + (pSquadOpt, action) match { + //the following action can be performed by the squad leader and maybe an unaffiliated player + case (Some(_), SelectRoleForYourself(_)) => + //TODO should be possible, but doesn't work correctly due to UI squad cards not updating as expected +// if(squad.Leader.CharId == tplayer.CharId) { +// //squad leader currently disallowed +// } else +// //the squad leader may swap to any open position; a normal member has to validate against requirements +// if(squad.Leader.CharId == tplayer.CharId || squad.isAvailable(position, tplayer.Certifications)) { +// squad.Membership.zipWithIndex.find { case (member, _) => member.CharId == tplayer.CharId } match { +// case Some((fromMember, fromIndex)) => +// SwapMemberPosition(squad.Membership(position), fromMember) +// Publish(squadFeatures(squad.GUID).ToChannel, SquadResponse.AssignMember(squad, fromIndex, position)) +// UpdateSquadDetail(squad) +// case _ => ; +// //somehow, this is not our squad; do nothing, for now +// } +// } +// else { +// //not qualified for requested position +// } + + //the following action can be performed by an unaffiliated player + case (None, SelectRoleForYourself(position)) => + //not a member of any squad, but we might become a member of this one + GetSquad(guid) match { + case Some(squad) => + if(squad.isAvailable(position, tplayer.Certifications)) { + //we could join but we may need permission from the squad leader first + AddInviteAndRespond( + squad.Leader.CharId, + RequestRole(tplayer, guid, position), + invitingPlayer = 0L, //we ourselves technically are ... + tplayer.Name + ) + } + case None => ; + //squad does not exist? assume old local data; force update to correct discrepancy + } + + //the following action can be performed by anyone who has tried to join a squad + case (_, CancelSelectRoleForYourself(_)) => + val cancellingPlayer = tplayer.CharId + GetSquad(guid) match { + case Some(squad) => + //assumption: a player who is cancelling will rarely end up with their invite queued + val leaderCharId = squad.Leader.CharId + //clean up any active RequestRole invite entry where we are the player who wants to join the leader's squad + ((invites.get(leaderCharId) match { + case out @ Some(entry) if entry.isInstanceOf[RequestRole] && + entry.asInstanceOf[RequestRole].player.CharId == cancellingPlayer => + out + case _ => + None + }) match { + case Some(entry : RequestRole) => + RemoveInvite(leaderCharId) + Publish(leaderCharId, SquadResponse.Membership(SquadResponseType.Cancel, 0, 0, cancellingPlayer, None, entry.player.Name, false, Some(None))) + NextInviteAndRespond(leaderCharId) + Some(true) + case _ => + None + }).orElse( + //look for a queued RequestRole entry where we are the player who wants to join the leader's squad + (queuedInvites.get(leaderCharId) match { + case Some(_list) => + (_list, _list.indexWhere { entry => + entry.isInstanceOf[RequestRole] && + entry.asInstanceOf[RequestRole].player.CharId == cancellingPlayer + }) + case None => + (Nil, -1) + }) match { + case (_, -1) => + None //no change + case (list, _) if list.size == 1 => + val entry = list.head.asInstanceOf[RequestRole] + Publish(leaderCharId, SquadResponse.Membership(SquadResponseType.Cancel, 0, 0, cancellingPlayer, None, entry.player.Name, false, Some(None))) + queuedInvites.remove(leaderCharId) + Some(true) + case (list, index) => + val entry = list(index).asInstanceOf[RequestRole] + Publish(leaderCharId, SquadResponse.Membership(SquadResponseType.Cancel, 0, 0, cancellingPlayer, None, entry.player.Name, false, Some(None))) + queuedInvites(leaderCharId) = list.take(index) ++ list.drop(index+1) + Some(true) + } + ) + + case _ => ; + } + + //the following action can be performed by ??? + case (Some(squad), AssignSquadMemberToRole(position, char_id)) => + val membership = squad.Membership.zipWithIndex + (membership.find({ case (member, _) => member.CharId == char_id}), membership(position)) match { + //TODO squad leader currently disallowed + case (Some((fromMember, fromPosition)), (toMember, _)) if fromPosition != 0 => + val name = fromMember.Name + SwapMemberPosition(toMember, fromMember) + Publish(squadFeatures(guid).ToChannel, SquadResponse.AssignMember(squad, fromPosition, position)) + UpdateSquadDetail( + squad.GUID, + SquadDetail().Members(List( + SquadPositionEntry(position, SquadPositionDetail().CharId(fromMember.CharId).Name(fromMember.Name)), + SquadPositionEntry(fromPosition, SquadPositionDetail().CharId(char_id).Name(name)) + )) + ) + case _ => ; + } + + //the following action can be peprformed by anyone + case (_, SearchForSquadsWithParticularRole(_/*role*/, _/*requirements*/, _/*zone_id*/, _/*search_mode*/)) => + //though we should be able correctly search squads as is intended + //I don't know how search results should be prioritized or even how to return search results to the user + Publish(sender, SquadResponse.SquadSearchResults()) + + //the following action can be performed by anyone + case (_, DisplaySquad()) => + val charId = tplayer.CharId + GetSquad(guid) match { + case Some(squad) if memberToSquad.get(charId).isEmpty => + continueToMonitorDetails += charId -> squad.GUID + Publish(sender, SquadResponse.Detail(squad.GUID, SquadService.Detail.Publish(squad))) + case Some(squad) => + Publish(sender, SquadResponse.Detail(squad.GUID, SquadService.Detail.Publish(squad))) + case _ => ; + } + + //the following message is feedback from a specific client, awaiting proper initialization + case (_, SquadMemberInitializationIssue()) => +// GetSquad(guid) match { +// case Some(squad) => +// Publish(sender, SquadResponse.Detail(squad.GUID, SquadService.Detail.Publish(squad))) +// case None => ; +// } + + case msg => ; + log.warn(s"Unsupported squad definition behavior: $msg") + } + } + + case SquadAction.Update(char_id, health, max_health, armor, max_armor, pos, zone_number) => + memberToSquad.get(char_id) match { + case Some(squad) => + squad.Membership.find(_.CharId == char_id) match { + case Some(member) => + member.Health = StatConverter.Health(health, max_health, min=1, max=64) + member.Armor = StatConverter.Health(armor, max_armor, min=1, max=64) + member.Position = pos + member.ZoneId = zone_number + Publish( + sender, + SquadResponse.UpdateMembers( + squad, + squad.Membership + .filterNot { _.CharId == 0 } + .map { member => SquadAction.Update(member.CharId, member.Health, 0, member.Armor, 0, member.Position, member.ZoneId) } + .toList + ) + ) + case _ => ; + } + + case None => ; + } + + case msg => + debug(s"Unhandled message $msg from $sender") + } + + case msg => + debug(s"Unhandled message $msg from $sender") + } + + /** + * This player has refused to join squad leader's squads or some other players's offers to form a squad. + * @param charId the player who refused other players + * @return the list of other players who have been refused + */ + def Refused(charId : Long) : List[Long] = refused.getOrElse(charId, Nil) + + /** + * This player has refused to join squad leader's squads or some other players's offers to form a squad. + * @param charId the player who is doing the refusal + * @param refusedCharId the player who is refused + * @return the list of other players who have been refused + */ + def Refused(charId : Long, refusedCharId : Long) : List[Long] = { + if(charId != refusedCharId) { + Refused(charId, List(refusedCharId)) + } + else { + Nil + } + } + + /** + * This player has refused to join squad leader's squads or some other players's offers to form a squad. + * @param charId the player who is doing the refusal + * @param list the players who are refused + * @return the list of other players who have been refused + */ + def Refused(charId : Long, list : List[Long]) : List[Long] = { + refused.get(charId) match { + case Some(refusedList) => + refused(charId) = list ++ refusedList + Refused(charId) + case None => + Nil + } + } + + /** + * Assign a provided invitation object to either the active or inactive position for a player.
+ *
+ * The determination for the active position is whether or not something is currently in the active position + * or whether some mechanism tried to shift invitation object into the active position + * but found nothing to shift. + * If an invitation object originating from the reported player already exists, + * a new one is not appended to the inactive queue. + * This method should always be used as the entry point for the active and inactive invitation options + * or as a part of the entry point for the aforesaid options. + * @see `AddInviteAndRespond` + * @see `AltAddInviteAndRespond` + * @param invitedPlayer the unique character identifier for the player being invited; + * in actuality, represents the player who will address the invitation object + * @param invite the "new" invitation envelop object + * @return an optional invite; + * the invitation object in the active invite position; + * `None`, if it is not added to either the active option or inactive position + */ + def AddInvite(invitedPlayer : Long, invite : Invitation) : Option[Invitation] = { + invites.get(invitedPlayer).orElse(previousInvites.get(invitedPlayer)) match { + case Some(_bid) => + //the active invite does not interact with the given invite; add to queued invites + queuedInvites.get(invitedPlayer) match { + case Some(bidList) => + //ensure that new invite does not interact with the queue's invites by invitingPlayer info + if(_bid.InviterCharId != invite.InviterCharId && !bidList.exists { eachBid => eachBid.InviterCharId == invite.InviterCharId }) { + queuedInvites(invitedPlayer) = invite match { + case _: RequestRole => + val (normals, others) = bidList.partition(_.isInstanceOf[RequestRole]) + (normals :+ invite) ++ others + case _ => + bidList :+ invite + } + Some(_bid) + } + else { + None + } + case None => + if(_bid.InviterCharId != invite.InviterCharId) { + queuedInvites(invitedPlayer) = List[Invitation](invite) + Some(_bid) + } + else { + None + } + } + + case None => + invites(invitedPlayer) = invite + Some(invite) + } + } + + /** + * Component method used for the response behavior for processing the invitation object as an `IndirectInvite` object. + * @see `HandleRequestRole` + * @param invite the original invitation object that started this process + * @param player the target of the response and invitation + * @param invitedPlayer the unique character identifier for the player being invited; + * in actuality, represents the player who will address the invitation object; + * not useful here + * @param invitingPlayer the unique character identifier for the player who invited the former; + * not useful here + * @param name a name to be used in message composition; + * not useful here + * @return na + */ + def indirectInviteResp(invite : IndirectInvite, player : Player, invitedPlayer : Long, invitingPlayer : Long, name : String) : Boolean = { + HandleRequestRole(invite, player) + } + + /** + * Component method used for the response behavior for processing the invitation object as an `IndirectInvite` object. + * @see `HandleRequestRole` + * @param invite the original invitation object that started this process + * @param player the target of the response and invitation + * @param invitedPlayer the unique character identifier for the player being invited + * in actuality, represents the player who will address the invitation object + * @param invitingPlayer the unique character identifier for the player who invited the former + * @param name a name to be used in message composition + * @return na + */ + def altIndirectInviteResp(invite : IndirectInvite, player : Player, invitedPlayer : Long, invitingPlayer : Long, name : String) : Boolean = { + Publish(invitingPlayer, SquadResponse.Membership(SquadResponseType.Accept, 0, 0, invitingPlayer, Some(invitedPlayer), player.Name, false, Some(None))) + HandleRequestRole(invite, player) + } + + /** + * A branched response for processing (new) invitation objects that have been submitted to the system.
+ *
+ * A comparison is performed between the original invitation object and an invitation object + * that represents the potential modification or redirection of the current active invitation obect. + * Any further action is only performed when an "is equal" comparison is `true`. + * When passing, the system publishes up to two messages + * to users that would anticipate being informed of squad join activity. + * @param indirectVacancyFunc the method that cans the respondign behavior should an `IndirectVacancy` object being consumed + * @param targetInvite a comparison invitation object; + * represents the unmodified, unadjusted invite + * @param actualInvite a comparaison invitation object; + * proper use of this field should be the output of another process upon the following `actualInvite` + * @param invitedPlayer the unique character identifier for the player being invited + * in actuality, represents the player who will address the invitation object + * @param invitingPlayer the unique character identifier for the player who invited the former + * @param name a name to be used in message composition + */ + def InviteResponseTemplate(indirectVacancyFunc : (IndirectInvite, Player, Long, Long, String) => Boolean)(targetInvite : Invitation, actualInvite : Option[Invitation], invitedPlayer : Long, invitingPlayer : Long, name : String) : Unit = { + if(actualInvite.contains(targetInvite)) { + //immediately respond + targetInvite match { + case VacancyInvite(charId, _name, _) => + Publish(invitedPlayer, SquadResponse.Membership(SquadResponseType.Invite, 0, 0, charId, Some(invitedPlayer), _name, false, Some(None))) + Publish(charId, SquadResponse.Membership(SquadResponseType.Invite, 0, 0, invitedPlayer, Some(charId), _name, true, Some(None))) + + case _bid @ IndirectInvite(player, _) => + indirectVacancyFunc(_bid, player, invitedPlayer, invitingPlayer, name) + + case _bid @ SpontaneousInvite(player) => + val bidInvitingPlayer = _bid.InviterCharId + Publish(invitedPlayer, SquadResponse.Membership(SquadResponseType.Invite, 0, 0, bidInvitingPlayer, Some(invitedPlayer), player.Name, false, Some(None))) + Publish(bidInvitingPlayer, SquadResponse.Membership(SquadResponseType.Invite, 0, 0, invitedPlayer, Some(bidInvitingPlayer), player.Name, true, Some(None))) + + case _bid @ RequestRole(player, _, _) => + HandleRequestRole(_bid, player) + + case LookingForSquadRoleInvite(charId, _name, _, _) => + Publish(invitedPlayer, SquadResponse.Membership(SquadResponseType.Invite, 0, 0, invitedPlayer, Some(charId), _name, false, Some(None))) + + case ProximityInvite(charId, _name, _) => + Publish(invitedPlayer, SquadResponse.Membership(SquadResponseType.Invite, 0, 0, invitedPlayer, Some(charId), _name, false, Some(None))) + + case _ => + log.warn(s"AddInviteAndRespond: can not parse discovered unhandled invitation type - $targetInvite") + } + } + } + + /** + * Enqueue a newly-submitted invitation object + * either as the active position or into the inactive positions + * and dispatch a response for any invitation object that is discovered. + * Implementation of a workflow. + * @see `AddInvite` + * @see `indirectInviteResp` + * @param targetInvite a comparison invitation object + * @param invitedPlayer the unique character identifier for the player being invited; + * in actuality, represents the player who will address the invitation object + * @param invitingPlayer the unique character identifier for the player who invited the former + * @param name a name to be used in message composition + */ + def AddInviteAndRespond(invitedPlayer : Long, targetInvite : Invitation, invitingPlayer : Long, name : String) : Unit = { + InviteResponseTemplate(indirectInviteResp)( + targetInvite, + AddInvite(invitedPlayer, targetInvite), + invitedPlayer, + invitingPlayer, + name + ) + } + + /** + * Enqueue a newly-submitted invitation object + * either as the active position or into the inactive positions + * and dispatch a response for any invitation object that is discovered. + * Implementation of a workflow. + * @see `AddInvite` + * @see `altIndirectInviteResp` + * @param targetInvite a comparison invitation object + * @param invitedPlayer the unique character identifier for the player being invited + * @param invitingPlayer the unique character identifier for the player who invited the former + * @param name a name to be used in message composition + */ + def AltAddInviteAndRespond(invitedPlayer : Long, targetInvite : Invitation, invitingPlayer : Long, name : String) : Unit = { + InviteResponseTemplate(altIndirectInviteResp)( + targetInvite, + AddInvite(invitedPlayer, targetInvite), + invitedPlayer, + invitingPlayer, + name + ) + } + + /** + * Select the next invitation object to be shifted into the active position.
+ *
+ * The determination for the active position is whether or not something is currently in the active position + * or whether some mechanism tried to shift invitation object into the active position + * but found nothing to shift. + * After handling of the previous invitation object has completed or finished, + * the temporary block on adding new invitations is removed + * and any queued inactive invitation on the head of the inactive queue is shifted into the active position. + * @see `NextInviteAndRespond` + * @param invitedPlayer the unique character identifier for the player being invited; + * in actuality, represents the player who will address the invitation object + * @return an optional invite; + * the invitation object in the active invite position; + * `None`, if not shifted into the active position + */ + def NextInvite(invitedPlayer : Long) : Option[Invitation] = { + previousInvites.remove(invitedPlayer) + invites.get(invitedPlayer) match { + case None => + queuedInvites.get(invitedPlayer) match { + case Some(list) => + list match { + case Nil => + None + case x :: Nil => + invites(invitedPlayer) = x + queuedInvites.remove(invitedPlayer) + Some(x) + case x :: xs => + invites(invitedPlayer) = x + queuedInvites(invitedPlayer) = xs + Some(x) + } + + case None => + None + } + case Some(_) => + None + } + } + + /** + * Select the next invitation object to be shifted into the active position + * and dispatch a response for any invitation object that is discovered. + * @see `InviteResponseTemplate` + * @see `NextInvite` + * @param invitedPlayer the unique character identifier for the player being invited; + * in actuality, represents the player who will address the invitation object + * @return an optional invite; + * the invitation object in the active invite position; + * `None`, if not shifted into the active position + */ + def NextInviteAndRespond(invitedPlayer : Long) : Unit = { + NextInvite(invitedPlayer) match { + case Some(invite) => + InviteResponseTemplate(indirectInviteResp)( + invite, + Some(invite), + invitedPlayer, + invite.InviterCharId, + invite.InviterName + ) + case None => ; + } + } + + /** + * Remove any invitation object from the active position. + * Flag the temporary field to indicate that the active position, while technically available, + * should not yet have a new invitation object shifted into it yet. + * This is the "proper" way to demote invitation objects from the active position + * whether or not they are to be handled. + * @see `NextInvite` + * @see `NextInviteAndRespond` + * @param invitedPlayer the unique character identifier for the player being invited; + * in actuality, represents the player who will address the invitation object + * @return an optional invite; + * the invitation object formerly in the active invite position; + * `None`, if no invitation was in the active position + */ + def RemoveInvite(invitedPlayer : Long) : Option[Invitation] = { + invites.remove(invitedPlayer) match { + case out @ Some(invite) => + previousInvites += invitedPlayer -> invite + out + case None => + None + } + } + + /** + * Remove all inactive invites. + * @param invitedPlayer the unique character identifier for the player being invited; + * in actuality, represents the player who will address the invitation object + * @return a list of the removed inactive invitation objects + */ + def RemoveQueuedInvites(invitedPlayer : Long) : List[Invitation] = { + queuedInvites.remove(invitedPlayer) match { + case Some(_bidList) => _bidList + case None => Nil + } + } + + /** + * Remove all active invitation objects that are related to the particular squad and the particular role in the squad. + * Specifically used to safely disarm obsolete invitation objects related to the specific criteria. + * Affects only certain invitation object types. + * @see `RequestRole` + * @see `LookingForSquadRoleInvite` + * @see `RemoveInvite` + * @see `RemoveQueuedInvitesForSquadAndPosition` + * @param guid the squad identifier + * @param position the role position index + */ + def RemoveInvitesForSquadAndPosition(guid : PlanetSideGUID, position : Int) : Unit = { + //eliminate active invites for this role + invites.collect { + case(charId, LookingForSquadRoleInvite(_,_, sguid, pos)) if sguid == guid && pos == position => + RemoveInvite(charId) + case (charId, RequestRole(_, sguid, pos)) if sguid == guid && pos == position => + RemoveInvite(charId) + } + RemoveQueuedInvitesForSquadAndPosition(guid, position) + } + + /** + * Remove all inactive invitation objects that are related to the particular squad and the particular role in the squad. + * Specifically used to safely disarm obsolete invitation objects by specific criteria. + * Affects only certain invitation object types. + * @see `RequestRole` + * @see `LookingForSquadRoleInvite` + * @see `RemoveInvitesForSquadAndPosition` + * @param guid the squad identifier + * @param position the role position index + */ + def RemoveQueuedInvitesForSquadAndPosition(guid : PlanetSideGUID, position : Int) : Unit = { + //eliminate other invites for this role + queuedInvites.foreach { case(charId, queue) => + val filtered = queue.filterNot { + case LookingForSquadRoleInvite(_,_, sguid, pos) => sguid == guid && pos == position + case RequestRole(_, sguid, pos) => sguid == guid && pos == position + case _ => false + } + if(filtered.isEmpty) { + queuedInvites.remove(charId) + } + else if(queue.size != filtered.size) { + queuedInvites += charId -> filtered + } + } + } + + /** + * Remove all active and inactive invitation objects that are related to the particular squad. + * Specifically used to safely disarm obsolete invitation objects by specific criteria. + * Affects all invitation object types and all data structures that deal with the squad. + * @see `RequestRole` + * @see `IndirectInvite` + * @see `LookingForSquadRoleInvite` + * @see `ProximityInvite` + * @see `RemoveInvite` + * @see `VacancyInvite` + * @param sguid the squad identifier + */ + def RemoveAllInvitesToSquad(sguid : PlanetSideGUID) : Unit = { + //clean up invites + invites.collect { + case (id, VacancyInvite(_, _, guid)) if sguid == guid => + RemoveInvite(id) + case (id, IndirectInvite(_, guid)) if sguid == guid => + RemoveInvite(id) + case (id, LookingForSquadRoleInvite(_, _, guid, _)) if sguid == guid => + RemoveInvite(id) + case (id, RequestRole(_, guid, _)) if sguid == guid => + RemoveInvite(id) + case (id, ProximityInvite(_, _, guid)) if sguid == guid => + RemoveInvite(id) + } + //tidy the queued invitations + queuedInvites.foreach { case(id, queue) => + val filteredQueue = queue.filterNot { + case VacancyInvite(_, _, guid) => sguid == guid + case IndirectInvite(_, guid) => sguid == guid + case LookingForSquadRoleInvite(_, _, guid, _) => sguid == guid + case RequestRole(_, guid, _) => sguid == guid + case ProximityInvite(_, _, guid) => sguid == guid + case _ => false + } + if(filteredQueue.isEmpty) { + queuedInvites.remove(id) + } + else if(filteredQueue.size != queue.size) { + queuedInvites.update(id, filteredQueue) + } + } + squadFeatures(sguid).ProxyInvites = Nil + squadFeatures(sguid).SearchForRole match { + case None => ; + case Some(_) => + squadFeatures(sguid).SearchForRole = None + } + continueToMonitorDetails.collect { + case (charId, guid) if sguid == guid => + continueToMonitorDetails.remove(charId) + } + } + + /** + * Remove all active and inactive invitation objects that are related to the particular player. + * Specifically used to safely disarm obsolete invitation objects by specific criteria. + * Affects all invitation object types and all data structures that deal with the player. + * @see `RequestRole` + * @see `IndirectInvite` + * @see `LookingForSquadRoleInvite` + * @see `RemoveInvite` + * @see `RemoveProximityInvites` + * @see `VacancyInvite` + * @param charId the player's unique identifier number + */ + def RemoveAllInvitesWithPlayer(charId : Long) : Unit = { + RemoveInvite(charId) + invites.collect { + case (id, SpontaneousInvite(player)) if player.CharId == charId => + RemoveInvite(id) + case (id, VacancyInvite(_charId, _, _)) if _charId == charId => + RemoveInvite(id) + case (id, IndirectInvite(player, _)) if player.CharId == charId => + RemoveInvite(id) + case (id, LookingForSquadRoleInvite(_charId, _, _, _)) if _charId == charId => + RemoveInvite(id) + case (id, RequestRole(player, _, _)) if player.CharId == charId => + RemoveInvite(id) + } + //tidy the queued invitations + queuedInvites.remove(charId) + queuedInvites.foreach { case(id, queue) => + val filteredQueue = queue.filterNot { + case SpontaneousInvite(player) => player.CharId == charId + case VacancyInvite(player, _, _) => player == charId + case IndirectInvite(player, _) => player.CharId == charId + case LookingForSquadRoleInvite(player, _, _, _) => player == charId + case RequestRole(player, _, _) => player.CharId == charId + case _ => false + } + if(filteredQueue.isEmpty) { + queuedInvites.remove(id) + } + else if(filteredQueue.size != queue.size) { + queuedInvites.update(id, filteredQueue) + } + } + continueToMonitorDetails.remove(charId) + RemoveProximityInvites(charId) + } + + /** + * Remove all active and inactive proximity squad invites related to the recruiter. + * @see `RemoveProximityInvites(Iterable[(Long, PlanetSideGUID)])` + * @param invitingPlayer the player who did the recruiting + * @return a list of all players (unique character identifier number and name) who had active proximity invitations + */ + def RemoveProximityInvites(invitingPlayer : Long) : Iterable[(Long, String)] = { + //invites + val (removedInvites, out) = invites.collect { + case (id, ProximityInvite(inviterCharId, inviterName, squadGUID)) if inviterCharId == invitingPlayer => + RemoveInvite(id) + ((id, squadGUID), (id, inviterName)) + }.unzip + RemoveProximityInvites(removedInvites) + //queued + RemoveProximityInvites(queuedInvites.flatMap { case (id : Long, inviteList) => + val (outList, inList) = inviteList.partition { + case ProximityInvite(inviterCharId, _, _) if inviterCharId == invitingPlayer => true + case _ => false + } + if(inList.isEmpty) { + queuedInvites.remove(id) + } + else { + queuedInvites(id) = inList + } + outList.collect { case ProximityInvite(_, _, sguid : PlanetSideGUID) => id -> sguid } + }) + out.toSeq.distinct + } + + /** + * Remove all queued proximity squad invite information retained by the squad object. + * @see `RemoveProximityInvites(Long)` + * @see `SquadFeatures.ProxyInvites` + * @see `SquadFeatures.SearchForRole` + * @param list a list of players to squads with expected entry redundancy + */ + def RemoveProximityInvites(list : Iterable[(Long, PlanetSideGUID)]) : Unit = { + val (_, squads) = list.unzip + squads.toSeq.distinct.foreach { squad => + squadFeatures.get(squad) match { + case Some(features) => + val out = list.collect { case (id, sguid) if sguid == squad => id } .toSeq + if((features.ProxyInvites = features.ProxyInvites filterNot out.contains) isEmpty) { + features.SearchForRole = None + } + case _ => ; + } + } + } + + /** + * Remove all active and inactive proximity squad invites for a specific squad. + * @param guid the squad + * @return a list of all players (unique character identifier number and name) who had active proximity invitations + */ + def RemoveProximityInvites(guid : PlanetSideGUID) : Iterable[(Long, String)] = { + //invites + val (removedInvites, out) = invites.collect { + case (id, ProximityInvite(_, inviterName, squadGUID)) if squadGUID == guid => + RemoveInvite(id) + (squadGUID, (id, inviterName)) + }.unzip + removedInvites.foreach { sguid => + squadFeatures(sguid).ProxyInvites = Nil + squadFeatures(sguid).SearchForRole = None + } + //queued + queuedInvites.flatMap { case (id : Long, inviteList) => + val (outList, inList) = inviteList.partition { + case ProximityInvite(_, _, squadGUID) if squadGUID == guid => true + case _ => false + } + if(inList.isEmpty) { + queuedInvites.remove(id) + } + else { + queuedInvites(id) = inList + } + outList.collect { case ProximityInvite(_, _, sguid : PlanetSideGUID) => + squadFeatures(sguid).ProxyInvites = Nil + squadFeatures(sguid).SearchForRole = None + } + } + out.toSeq.distinct + } + + /** + * Resolve an invitation to a general, not guaranteed, position in someone else's squad. + * For the moment, just validate the provided parameters and confirm the eligibility of the user. + * @see `VacancyInvite` + * @param squad_guid the unique squad identifier number + * @param invitedPlayer the unique character identifier for the player being invited + * @param invitingPlayer the unique character identifier for the player who invited the former + * @param recruit the player being invited + * @return the squad object and a role position index, if properly invited; + * `None`, otherwise + */ + def HandleVacancyInvite(squad_guid : PlanetSideGUID, invitedPlayer : Long, invitingPlayer : Long, recruit : Player) : Option[(Squad, Int)] = { + squadFeatures.get(squad_guid) match { + case Some(features) => + val squad = features.Squad + memberToSquad.get(invitedPlayer) match { + case Some(enrolledSquad) => + if(enrolledSquad eq squad) { + log.warn(s"HandleVacancyInvite: ${recruit.Name} is already a member of squad ${squad.Task}") + } + else { + log.warn(s"HandleVacancyInvite: ${recruit.Name} is a member of squad ${enrolledSquad.Task} and can not join squad ${squad.Task}") + } + None + case _ => + HandleVacancyInvite(squad, invitedPlayer, invitingPlayer, recruit) + } + + case _ => + log.warn(s"HandleVacancyInvite: the squad #${squad_guid.guid} no longer exists") + None + } + } + + /** + * Resolve an invitation to a general, not guaranteed, position in someone else's squad.
+ *
+ * Originally, the instigating type of invitation object was a "`VacancyInvite`" + * which indicated a type of undirected invitation extended from the squad leader to another player + * but the resolution is generalized enough to suffice for a number of invitation objects. + * First, an actual position is determined; + * then, the squad is tested for recruitment conditions, + * including whether the person who solicited the would-be member is still the squad leader. + * If the recruitment is manual and the squad leader is not the same as the recruiting player, + * then the real squad leader is sent an indirect query regarding the player's eligibility. + * These `IndirectInvite` invitation objects also are handled by calls to `HandleVacancyInvite`. + * @see `AltAddInviteAndRespond` + * @see `IndirectInvite` + * @see `SquadFeatures::AutoApproveInvitationRequests` + * @see `VacancyInvite` + * @param squad the squad + * @param invitedPlayer the unique character identifier for the player being invited + * @param invitingPlayer the unique character identifier for the player who invited the former + * @param recruit the player being invited + * @return the squad object and a role position index, if properly invited; + * `None`, otherwise + */ + def HandleVacancyInvite(squad : Squad, invitedPlayer : Long, invitingPlayer : Long, recruit : Player) : Option[(Squad, Int)] = { + //accepted an invitation to join an existing squad + squad.Membership.zipWithIndex.find({ case (_, index) => + squad.isAvailable(index, recruit.Certifications) + }) match { + case Some((_, line)) => + //position in squad found + val sguid = squad.GUID + val features = squadFeatures(sguid) + if(!features.AutoApproveInvitationRequests && squad.Leader.CharId != invitingPlayer) { + //the inviting player was not the squad leader and this decision should be bounced off the squad leader + AltAddInviteAndRespond( + squad.Leader.CharId, + IndirectInvite(recruit, sguid), + invitingPlayer, + name = "" + ) + debug(s"HandleVacancyInvite: ${recruit.Name} must await an invitation from the leader of squad ${squad.Task}") + None + } + else { + Some((squad, line)) + } + case _ => + None + } + } + + /** + * An overloaded entry point to the functionality for handling one player requesting a specific squad role. + * @param bid a specific kind of `Invitation` object + * @param player the player who wants to join the squad + * @return `true`, if the player is not denied the possibility of joining the squad; + * `false`, otherwise, of it the squad does not exist + */ + def HandleRequestRole(bid : RequestRole, player : Player) : Boolean = { + HandleRequestRole(bid, bid.squad_guid, player) + } + + /** + * An overloaded entry point to the functionality for handling indirection when messaging the squad leader about an invite. + * @param bid a specific kind of `Invitation` object + * @param player the player who wants to join the squad + * @return `true`, if the player is not denied the possibility of joining the squad; + * `false`, otherwise, of it the squad does not exist + */ + def HandleRequestRole(bid : IndirectInvite, player : Player) : Boolean = { + HandleRequestRole(bid, bid.squad_guid, player) + } + + /** + * The functionality for handling indirection + * for handling one player requesting a specific squad role + * or when messaging the squad leader about an invite.
+ *
+ * At this point in the squad join process, the only consent required is that of the squad leader. + * An automatic consent flag exists on the squad; + * but, if that is not set, then the squad leader must be asked whether or not to accept or to reject the recruit. + * If the squad leader changes in the middle of the latter half of the process, + * the invitation may still fail even if the old squad leader accepts. + * If the squad leader changes in the middle of the latter half of the process, + * the inquiry might be posed again of the new squad leader, of whether to accept or to reject the recruit. + * @param bid the `Invitation` object that was the target of this request + * @param guid the unique squad identifier number + * @param player the player who wants to join the squad + * @return `true`, if the player is not denied the possibility of joining the squad; + * `false`, otherwise, of it the squad does not exist + */ + def HandleRequestRole(bid : Invitation, guid : PlanetSideGUID, player : Player) : Boolean = { + squadFeatures.get(guid) match { + case Some(features) => + val leaderCharId = features.Squad.Leader.CharId + if(features.AutoApproveInvitationRequests) { + self ! SquadServiceMessage(player, Zone.Nowhere, SquadAction.Membership(SquadRequestType.Accept, leaderCharId, None, "", None)) + } + else { + Publish(leaderCharId, SquadResponse.WantsSquadPosition(leaderCharId, player.Name)) + } + true + case _ => + //squad is missing + log.error(s"Attempted to process ${bid.InviterName}'s bid for a position in a squad that does not exist") + false + } + } + + /** + * Pertains to the original message of squad synchronicity sent to the squad leader by the server under specific conditions. + * The initial formation of a squad of two players is the most common expected situation. + * While the underlying flag is normally only set once, its state can be reset and triggered anew if necessary. + * @see `Publish` + * @see ``ResetAll + * @see `SquadResponse.AssociateWithSquad` + * @see `SquadResponse.Detail` + * @see `SquadService.Detail.Publish` + * @param squad the squad + */ + def InitialAssociation(squad : Squad) : Unit = { + val guid = squad.GUID + if(squadFeatures(guid).InitialAssociation) { + squadFeatures(guid).InitialAssociation = false + val charId = squad.Leader.CharId + Publish(charId, SquadResponse.AssociateWithSquad(guid)) + Publish(charId, SquadResponse.Detail(guid, SquadService.Detail.Publish(squad))) + } + } + + /** + * Establish a new squad. + * Create all of the support structures for the squad and link into them. + * At a minimum, by default, the squad needs a squad leader + * and a stronger, more exposed connection between the squad and leader needs to be recognized.
+ *
+ * Usually, a squad is created by modifying some aspect of its necessary fields. + * The primary necessary fields required for a squad include the squad's task and the squad's zone of operation. + * @see `GetNextSquadId` + * @see `Squad` + * @see `SquadFeatures` + * @see `SquadFeatures::Start` + * @param player the player who would become the squad leader + * @return the squad that has been created + */ + def StartSquad(player : Player) : Squad = { + val faction = player.Faction + val name = player.Name + val squad = new Squad(GetNextSquadId(), faction) + val leadPosition = squad.Membership(0) + leadPosition.Name = name + leadPosition.CharId = player.CharId + leadPosition.Health = player.Health + leadPosition.Armor = player.Armor + leadPosition.Position = player.Position + leadPosition.ZoneId = 1 + squadFeatures += squad.GUID -> new SquadFeatures(squad).Start + memberToSquad += squad.Leader.CharId -> squad + debug(s"$name-$faction has created a new squad") + squad + } + + /** + * Behaviors and exchanges necessary to complete the fulfilled recruitment process for the squad role.
+ *
+ * This operation is fairly safe to call whenever a player is to be inducted into a squad. + * The aforementioned player must have a callback retained in `UserEvents` + * and conditions imposed by both the role and the player must be satisfied. + * @see `InitialAssociation` + * @see `InitSquadDetail` + * @see `InitWaypoints` + * @see `Publish` + * @see `RemoveAllInvitesWithPlayer` + * @see `SquadDetail` + * @see `SquadInfo` + * @see `SquadPositionDetail` + * @see `SquadPositionEntry` + * @see `SquadResponse.Join` + * @see `StatConverter.Health` + * @see `UpdateSquadListWhenListed` + * @param player the new squad member; + * this player is NOT the squad leader + * @param squad the squad the player is joining + * @param position the squad member role that the player will be filling + * @return `true`, if the player joined the squad in some capacity; + * `false`, if the player did not join the squad or is already a squad member + */ + def JoinSquad(player : Player, squad : Squad, position : Int) : Boolean = { + val charId = player.CharId + val role = squad.Membership(position) + if(UserEvents.get(charId).nonEmpty && squad.Leader.CharId != charId && squad.isAvailable(position, player.Certifications)) { + role.Name = player.Name + role.CharId = charId + role.Health = StatConverter.Health(player.Health, player.MaxHealth, min=1, max=64) + role.Armor = StatConverter.Health(player.Armor, player.MaxArmor, min=1, max=64) + role.Position = player.Position + role.ZoneId = 1 + memberToSquad(charId) = squad + + continueToMonitorDetails.remove(charId) + RemoveAllInvitesWithPlayer(charId) + InitialAssociation(squad) + Publish(charId, SquadResponse.AssociateWithSquad(squad.GUID)) + val features = squadFeatures(squad.GUID) + val size = squad.Size + if(size == 2) { + //first squad member after leader; both members fully initialize + val (memberCharIds, indices) = squad.Membership + .zipWithIndex + .filterNot { case (member, _) => member.CharId == 0 } + .toList + .unzip { case (member, index) => (member.CharId, index) } + val toChannel = features.ToChannel + memberCharIds.foreach { charId => + SquadEvents.subscribe(UserEvents(charId), s"/$toChannel/Squad") + Publish(charId, + SquadResponse.Join( + squad, + indices.filterNot(_ == position) :+ position, + toChannel + ) + ) + InitWaypoints(charId, squad.GUID) + } + //fully update for all users + InitSquadDetail(squad) + } + else { + //joining an active squad; everybody updates differently + val updatedIndex = List(position) + val toChannel = features.ToChannel + //new member gets full squad UI updates + Publish( + charId, + SquadResponse.Join( + squad, + position +: squad.Membership + .zipWithIndex + .collect({ case (member, index) if member.CharId > 0 => index }) + .filterNot(_ == position) + .toList, + toChannel + ) + ) + //other squad members see new member joining the squad + Publish(toChannel, SquadResponse.Join(squad, updatedIndex, "")) + InitWaypoints(charId, squad.GUID) + InitSquadDetail(squad.GUID, Seq(charId), squad) + UpdateSquadDetail( + squad.GUID, + SquadDetail().Members(List(SquadPositionEntry(position, SquadPositionDetail().CharId(charId).Name(player.Name)))) + ) + SquadEvents.subscribe(UserEvents(charId), s"/$toChannel/Squad") + } + UpdateSquadListWhenListed(features, SquadInfo().Size(size)) + true + } + else { + false + } + } + + /** + * Determine whether a player is sufficiently unemployed + * and has no grand delusions of being a squad leader. + * @see `CloseSquad` + * @param charId the player + * @return `true`, if the target player possesses no squad or a squad that is suitably nonexistent; + * `false`, otherwise + */ + def EnsureEmptySquad(charId : Long) : Boolean = { + memberToSquad.get(charId) match { + case None => + true + case Some(squad) if squad.Size == 1 => + CloseSquad(squad) + true + case _ => + log.warn("EnsureEmptySquad: the invited player is already a member of a squad and can not join a second one") + false + } + } + + /** + * Behaviors and exchanges necessary to undo the recruitment process for the squad role. + * @see `PanicLeaveSquad` + * @see `Publish` + * @param charId the player + * @param squad the squad + * @return `true`, if the player, formerly a normal member of the squad, has been ejected from the squad; + * `false`, otherwise + */ + def LeaveSquad(charId : Long, squad : Squad) : Boolean = { + val membership = squad.Membership.zipWithIndex + membership.find { case (_member, _) => _member.CharId == charId } match { + case data @ Some((_, index)) if squad.Leader.CharId != charId => + PanicLeaveSquad(charId, squad, data) + //member leaves the squad completely (see PanicSquadLeave) + Publish( + charId, + SquadResponse.Leave( + squad, + (charId, index) +: membership + .collect { case (_member, _index) if _member.CharId > 0 && _member.CharId != charId => (_member.CharId, _index) } + .toList + ) + ) + SquadEvents.unsubscribe(UserEvents(charId), s"/${squadFeatures(squad.GUID).ToChannel}/Squad") + true + case _ => + false + } + } + + /** + * Behaviors and exchanges necessary to undo the recruitment process for the squad role.
+ *
+ * The complement of the prior `LeaveSquad` method. + * This method deals entirely with other squad members observing the given squad member leaving the squad + * while the other method handles messaging only for the squad member who is leaving. + * The distinction is useful when unsubscribing from this service, + * as the `ActorRef` object used to message the player's client is no longer reliable + * and has probably ceased to exist. + * @see `LeaveSquad` + * @see `Publish` + * @see `SquadDetail` + * @see `SquadInfo` + * @see `SquadPositionDetail` + * @see `SquadPositionEntry` + * @see `SquadResponse.Leave` + * @see `UpdateSquadDetail` + * @see `UpdateSquadListWhenListed` + * @param charId the player + * @param squad the squad + * @param entry a paired membership role with its index in the squad + * @return if a role/index pair is provided + */ + def PanicLeaveSquad(charId : Long, squad : Squad, entry : Option[(Member, Int)]) : Boolean = { + entry match { + case Some((member, index)) => + val entry = (charId, index) + //member leaves the squad completely + memberToSquad.remove(charId) + member.Name = "" + member.CharId = 0 + //other squad members see the member leaving + Publish(squadFeatures(squad.GUID).ToChannel, SquadResponse.Leave(squad, List(entry)), Seq(charId)) + UpdateSquadListWhenListed(squadFeatures(squad.GUID), SquadInfo().Size(squad.Size)) + UpdateSquadDetail( + squad.GUID, + SquadDetail().Members(List(SquadPositionEntry(index, SquadPositionDetail().Player(char_id = 0, name = "")))) + ) + true + case None => + false + } + } + + /** + * All players are made to leave the squad and the squad will stop existing. + * Any member of the squad missing an `ActorRef` object used to message the player's client + * will still leave the squad, but will not attempt to send feedback to the said unreachable client. + * If the player is in the process of unsubscribing from the service, + * the no-messaging pathway is useful to avoid accumulating dead letters. + * @see `Publish` + * @see `RemoveAllInvitesToSquad` + * @see `SquadDetail` + * @see `TryResetSquadId` + * @see `UpdateSquadList` + * @param squad the squad + */ + def CloseSquad(squad : Squad) : Unit = { + val guid = squad.GUID + RemoveAllInvitesToSquad(guid) + val membership = squad.Membership.zipWithIndex + val (updateMembers, updateIndices) = membership + .collect { case (member, index) if member.CharId > 0 => ((member, member.CharId, index, UserEvents.get(member.CharId)), (member.CharId, index)) } + .unzip + val updateIndicesList = updateIndices.toList + val completelyBlankSquadDetail = SquadDetail().Complete + val features = squadFeatures(guid) + val channel = s"/${features.ToChannel}/Squad" + if(features.Listed) { + Publish(squad.Leader.CharId, SquadResponse.SetListSquad(PlanetSideGUID(0))) + } + updateMembers + .foreach { + case (member, charId, _, None) => + memberToSquad.remove(charId) + member.Name = "" + member.CharId = 0L + case (member, charId, index, Some(actor)) => + memberToSquad.remove(charId) + member.Name = "" + member.CharId = 0L + SquadEvents.unsubscribe(actor, channel) + Publish( + charId, + SquadResponse.Leave( + squad, + updateIndicesList.filterNot { case (_, outIndex) => outIndex == index } :+ (charId, index) //we need to be last + ) + ) + Publish(charId, SquadResponse.AssociateWithSquad(PlanetSideGUID(0))) + Publish(charId, SquadResponse.Detail(PlanetSideGUID(0), completelyBlankSquadDetail)) + } + UpdateSquadListWhenListed( + squadFeatures.remove(guid).get.Stop, + None + ) + } + + /** + * All players are made to leave the squad and the squad will stop existing. + * Essentially, perform the same operations as `CloseSquad` + * but treat the process as if the squad is being disbanded in terms of messaging. + * @see `PanicDisbandSquad` + * @see `Publish` + * @see `SquadResponse.Membership` + * @param squad the squad + */ + def DisbandSquad(squad : Squad) : Unit = { + val leader = squad.Leader.CharId + PanicDisbandSquad( + squad, + squad.Membership.collect { case member if member.CharId > 0 && member.CharId != leader => member.CharId } + ) + //the squad is being disbanded, the squad events channel is also going away; use cached character ids + Publish(leader, SquadResponse.Membership(SquadResponseType.Disband, 0, 0, leader, None, "", true, Some(None))) + } + + /** + * All players are made to leave the squad and the squad will stop existing.
+ *
+ * The complement of the prior `DisbandSquad` method. + * This method deals entirely with other squad members observing the squad become abandoned. + * The distinction is useful when unsubscribing from this service, + * as the `ActorRef` object used to message the player's client is no longer reliable + * and has probably ceased to exist. + * @see `CloseSquad` + * @see `DisbandSquad` + * @see `Publish` + * @see `SquadResponse.Membership` + * @param squad the squad + * @param membership the unique character identifier numbers of the other squad members + * @return if a role/index pair is provided + */ + def PanicDisbandSquad(squad : Squad, membership : Iterable[Long]) : Unit = { + CloseSquad(squad) + membership.foreach { charId => + Publish(charId, SquadResponse.Membership(SquadResponseType.Disband, 0, 0, charId, None, "", false, Some(None))) + } + } + + /** + * Move one player into one squad role and, + * if encountering a player already recruited to the destination role, + * swap that other player into the first player's position. + * If no encounter, just blank the original role. + * @see `AssignSquadMemberToRole` + * @see `SelectRoleForYourself` + * @param toMember the squad role where the player is being placed + * @param fromMember the squad role where the player is being encountered; + * if a conflicting player is discovered, swap that player into `fromMember` + */ + def SwapMemberPosition(toMember: Member, fromMember: Member) : Unit = { + val (name, charId, zoneId, pos, health, armor) = (fromMember.Name, fromMember.CharId, fromMember.ZoneId, fromMember.Position, fromMember.Health, fromMember.Armor) + if(toMember.CharId > 0) { + fromMember.Name = toMember.Name + fromMember.CharId = toMember.CharId + fromMember.ZoneId = toMember.ZoneId + fromMember.Position = toMember.Position + fromMember.Health = toMember.Health + fromMember.Armor = toMember.Armor + } + else { + fromMember.Name = "" + fromMember.CharId = 0L + } + toMember.Name = name + toMember.CharId = charId + toMember.ZoneId = zoneId + toMember.Position = pos + toMember.Health = health + toMember.Armor = armor + } + + /** + * Display the indicated waypoint.
+ *
+ * Despite the name, no waypoints are actually "added." + * All of the waypoints constantly exist as long as the squad to which they are attached exists. + * They are merely "activated" and "deactivated." + * @see `SquadWaypointRequest` + * @see `WaypointInfo` + * @param guid the squad's unique identifier + * @param waypointType the type of the waypoint + * @param info information about the waypoint, as was reported by the client's packet + * @return the waypoint data, if the waypoint type is changed + */ + def AddWaypoint(guid : PlanetSideGUID, waypointType : SquadWaypoints.Value, info : WaypointInfo) : Option[WaypointData] = { + squadFeatures.get(guid) match { + case Some(features) => + features.Waypoints.lift(waypointType.id) match { + case Some(point) => + point.zone_number = info.zone_number + point.pos = info.pos + Some(point) + case None => + log.error(s"no squad waypoint $waypointType found") + None + } + case None => + log.error(s"no squad waypoint $waypointType found") + None + } + } + + /** + * Hide the indicated waypoint. + * Unused waypoints are marked by having a non-zero z-coordinate.
+ *
+ * Despite the name, no waypoints are actually "removed." + * All of the waypoints constantly exist as long as the squad to which they are attached exists. + * They are merely "activated" and "deactivated." + * @param guid the squad's unique identifier + * @param waypointType the type of the waypoint + */ + def RemoveWaypoint(guid : PlanetSideGUID, waypointType : SquadWaypoints.Value) : Unit = { + squadFeatures.get(guid) match { + case Some(features) => + features.Waypoints.lift(waypointType.id) match { + case Some(point) => + point.pos = Vector3.z(1) + case _ => + log.warn(s"no squad waypoint $waypointType found") + } + case _ => + log.warn(s"no squad #$guid found") + } + } + + /** + * Dispatch all of the information about a given squad's waypoints to a user. + * @param toCharId the player to whom the waypoint data will be dispatched + * @param guid the squad's unique identifier + */ + def InitWaypoints(toCharId : Long, guid : PlanetSideGUID) : Unit = { + squadFeatures.get(guid) match { + case Some(features) => + val squad = features.Squad + val vz1 = Vector3.z(value = 1) + val list = features.Waypoints + Publish( + toCharId, SquadResponse.InitWaypoints(squad.Leader.CharId, + list.zipWithIndex.collect { case (point, index) if point.pos != vz1 => + (SquadWaypoints(index), WaypointInfo(point.zone_number, point.pos), 1) + } + ) + ) + case None => ; + } + } + + /** + * na + * @param charId the player's unique character identifier number + * @param sender the `ActorRef` associated with this character + */ + def LeaveService(charId : String, sender : ActorRef) : Unit = { + LeaveService(charId.toLong, sender) + } + + /** + * na + * @param charId the player's unique character identifier number + * @param sender the `ActorRef` associated with this character + */ + def LeaveService(charId : Long, sender : ActorRef) : Unit = { + refused.remove(charId) + continueToMonitorDetails.remove(charId) + RemoveAllInvitesWithPlayer(charId) + val pSquadOpt = GetParticipatingSquad(charId) + val lSquadOpt = GetLeadingSquad(charId, pSquadOpt) + pSquadOpt match { + //member of the squad; leave the squad + case Some(squad) => + val size = squad.Size + SquadEvents.unsubscribe(UserEvents(charId), s"/${squadFeatures(squad.GUID).ToChannel}/Squad") + UserEvents.remove(charId) + lSquadOpt match { + case Some(_) => + //leader of a squad; the squad will be disbanded + PanicDisbandSquad(squad, squad.Membership.collect { case member if member.CharId > 0 && member.CharId != charId => member.CharId }) + case None if size == 2 => + //one of the last two members of a squad; the squad will be disbanded + PanicDisbandSquad(squad, squad.Membership.collect { case member if member.CharId > 0 && member.CharId != charId => member.CharId }) + case None => + //not the leader of the squad; tell other members that we are leaving + PanicLeaveSquad(charId, squad, squad.Membership.zipWithIndex.find { case (_member, _) => _member.CharId == charId }) + } + case None => + //not a member of any squad; nothing to do here + UserEvents.remove(charId) + } + SquadEvents.unsubscribe(sender) //just to make certain + TryResetSquadId() + } + + /** + * Dispatch a message entailing the composition of this squad when that squad is publicly available + * and focus on any specific aspects of it, purported as being changed recently. + * @see `SquadInfo` + * @see `UpdateSquadList(Squad, Option[SquadInfo])` + * @param features the related information about the squad + * @param changes the highlighted aspects of the squad; + * these "changes" do not have to reflect the actual squad but are related to the contents of the message + */ + private def UpdateSquadListWhenListed(features : SquadFeatures, changes : SquadInfo) : Unit = { + UpdateSquadListWhenListed(features, Some(changes)) + } + + /** + * Dispatch a message entailing the composition of this squad when that squad is publicly available + * and focus on any specific aspects of it, purported as being changed recently. + * The only requirement is that the squad is publicly available for recruitment ("listed"). + * @see `SquadInfo` + * @see `UpdateSquadList(Squad, Option[SquadInfo])` + * @param features the related information about the squad + * @param changes the optional highlighted aspects of the squad; + * these "changes" do not have to reflect the actual squad but are related to the contents of the message + */ + private def UpdateSquadListWhenListed(features : SquadFeatures, changes : Option[SquadInfo]) : Unit = { + val squad = features.Squad + if(features.Listed) { + UpdateSquadList(squad, changes) + } + } + + /** + * Dispatch a message entailing the composition of this squad + * and focus on any specific aspects of it, purported as being changed recently.
+ *
+ * What sort of message is dispatched is not only based on the input parameters + * but also on the state of previously listed squad information. + * Listed squad information is queued when it is first published, organized first by faction affinity, then by chronology. + * The output is first determinate on whether the squad had previously been listed as available. + * If so, it will either update its data to all valid faction associated entities with the provided changed data; + * or, it will be removed from the list of available squads, if there is no provided change data. + * If the squad can not be found, + * the change data, whatever it is, is unimportant, and the squad will be listed in full for the first time.
+ *
+ * When a squad is first introduced to the aforementioned list, + * thus first being published to all faction-associated parties, + * the entirety of the squad list for that faction will be updated in one go. + * It is not necessary to do this, but doing so saves index and unique squad identifier management + * at the cost of the size of the packet to be dispatched. + * When a squad is removed to the aforementioned list, + * the same process occurs where the full list for that faction affiliation is sent as an update. + * The procedure for updating individual squad fields is precise and targeted, + * and has been or should be prepared in advance by the caller to this method. + * As a consequence, when updating the entry for that squad, + * the information used as the update does not necessarily reflect the actual information currently in the squad. + * @see `SquadResponse.InitList` + * @see `SquadResponse.UpdateList` + * @see `SquadService.SquadList.Publish` + * @param squad the squad + * @param changes the optional highlighted aspects of the squad; + * these "changes" do not have to reflect the actual squad but are related to the contents of the message + */ + def UpdateSquadList(squad : Squad, changes : Option[SquadInfo]) : Unit = { + val guid = squad.GUID + val faction = squad.Faction + val factionListings = publishedLists(faction) + factionListings.find(_ == guid) match { + case Some(listedSquad) => + val index = factionListings.indexOf(listedSquad) + changes match { + case Some(changedFields) => + //squad information update + Publish(faction, SquadResponse.UpdateList(Seq((index, changedFields)))) + case None => + //remove squad from listing + factionListings.remove(index) + //Publish(faction, SquadResponse.RemoveFromList(Seq(index))) + Publish(faction, SquadResponse.InitList(PublishedLists(factionListings))) + } + case None => + //first time being published + factionListings += guid + Publish(faction, SquadResponse.InitList(PublishedLists(factionListings))) + } + } + + /** + * Dispatch a message entailing the composition of this squad. + * This is considered the first time this information will be dispatched to any relevant observers + * so the details of the squad will be updated in full and be sent to all relevant observers, + * namely, all the occupants of the squad. + * External observers are ignored. + * @see `InitSquadDetail(PlanetSideGUID, Iterable[Long], Squad)` + * @param squad the squad + */ + def InitSquadDetail(squad : Squad) : Unit = { + InitSquadDetail( + squad.GUID, + squad.Membership.collect { case member if member.CharId > 0 => member.CharId }, + squad + ) + } + + /** + * Dispatch an intial message entailing the strategic information and the composition of this squad. + * The details of the squad will be updated in full and be sent to all indicated observers. + * @see `SquadService.Detail.Publish` + * @param guid the unique squad identifier to be used when composing the details for this message + * @param to the unique character identifier numbers of the players who will receive this message + * @param squad the squad from which the squad details shall be composed + */ + def InitSquadDetail(guid : PlanetSideGUID, to : Iterable[Long], squad : Squad) : Unit = { + val output = SquadResponse.Detail(guid, SquadService.Detail.Publish(squad)) + to.foreach { Publish(_, output) } + } + + /** + * Send a message entailing the strategic information and the composition of the squad to the existing members of the squad. + * @see `SquadService.Detail.Publish` + * @see `UpdateSquadDetail(PlanetSideGUID, PlanetSideGUID, List[Long], SquadDetail)` + * @param squad the squad + */ + def UpdateSquadDetail(squad : Squad) : Unit = { + UpdateSquadDetail( + squad.GUID, + squad.GUID, + Nil, + SquadService.Detail.Publish(squad) + ) + } + + /** + * Send a message entailing the strategic information and the composition of the squad to the existing members of the squad. + * Rather than using the squad's existing unique identifier number, + * a meaningful substitute identifier will be employed in the message. + * The "meaningful substitute" is usually `PlanetSideGUID(0)` + * which indicates the local non-squad squad data on the client of a squad leader. + * @see `SquadService.Detail.Publish` + * @see `UpdateSquadDetail(PlanetSideGUID, PlanetSideGUID, List[Long], SquadDetail)` + * @param squad the squad + */ + def UpdateSquadDetail(guid : PlanetSideGUID, squad : Squad) : Unit = { + UpdateSquadDetail( + guid, + squad.GUID, + Nil, + SquadService.Detail.Publish(squad) + ) + } + + /** + * Send Send a message entailing some of the strategic information and the composition to the existing members of the squad. + * @see `SquadResponse.Detail` + * @see `UpdateSquadDetail(PlanetSideGUID, PlanetSideGUID, List[Long], SquadDetail)` + * @param guid the unique identifier number of the squad + * @param details the squad details to be included in the message + */ + def UpdateSquadDetail(guid : PlanetSideGUID, details : SquadDetail) : Unit = { + UpdateSquadDetail( + guid, + guid, + Nil, + details + ) + } + + /** + * Send a message entailing some of the strategic information and the composition to the existing members of the squad. + * Also send the same information to any users who are watching the squad, potentially for want to join it. + * The squad-specific message is contingent on finding the squad's features using the unique identifier number + * and, from that, reporting to the specific squad's messaging channel. + * Anyone watching the squad will always be updated the given details. + * @see `DisplaySquad` + * @see `Publish` + * @see `SquadDetail` + * @see `SquadResponse.Detail` + * @param guid the unique squad identifier number to be used for the squad detail message + * @param toGuid the unique squad identifier number indicating the squad broadcast channel name + * @param excluding the explicit unique character identifier numbers of individuals who should not receive the message + * @param details the squad details to be included in the message + */ + def UpdateSquadDetail(guid : PlanetSideGUID, toGuid : PlanetSideGUID, excluding : Iterable[Long], details : SquadDetail) : Unit = { + val output = SquadResponse.Detail(guid, details) + squadFeatures.get(toGuid) match { + case Some(features) => + Publish(features.ToChannel, output, excluding) + case _ => ; + } + continueToMonitorDetails + .collect { case (charId, sguid) if sguid == guid && !excluding.exists(_ == charId) => + Publish(charId, output, Nil) + } + } + + /** + * Transform a list of squad unique identifiers into a list of `SquadInfo` objects for updating the squad list window. + * @param faction the faction to which the squads belong + * @return a `Vector` of transformed squad data + */ + def PublishedLists(faction : PlanetSideEmpire.Type) : Vector[SquadInfo] = { + PublishedLists(publishedLists(faction)) + } + /** + * Transform a list of squad unique identifiers into a list of `SquadInfo` objects for updating the squad list window. + * @param guids the list of squad unique identifier numbers + * @return a `Vector` of transformed squad data + */ + def PublishedLists(guids : Iterable[PlanetSideGUID]) : Vector[SquadInfo] = { + guids.map {guid => SquadService.SquadList.Publish(squadFeatures(guid).Squad) }.toVector + } +} + +object SquadService { + + /** + * Information necessary to display a specific map marker. + */ + class WaypointData() { + var zone_number : Int = 1 + var pos : Vector3 = Vector3.z(1) //a waypoint with a non-zero z-coordinate will flag as not getting drawn + } + + /** + * The base of all objects that exist for the purpose of communicating invitation from one player to the next. + * @param char_id the inviting player's unique identifier number + * @param name the inviting player's name + */ + abstract class Invitation(char_id : Long, name : String) { + def InviterCharId : Long = char_id + def InviterName : String = name + } + + /** + * Utilized when one player attempts to join an existing squad in a specific role. + * Accessed by the joining player from the squad detail window. + * @param player the player who requested the role + * @param squad_guid the squad with the role + * @param position the index of the role + */ + final case class RequestRole(player : Player, squad_guid : PlanetSideGUID, position : Int) + extends Invitation(player.CharId, player.Name) + + /** + * Utilized when one squad member issues an invite for some other player. + * Accessed by an existing squad member using the "Invite" menu option on another player. + * @param char_id the unique character identifier of the player who sent the invite + * @param name the name the player who sent the invite + * @param squad_guid the squad + */ + final case class VacancyInvite(char_id : Long, name : String, squad_guid : PlanetSideGUID) + extends Invitation(char_id, name) + + /** + * Utilized to redirect an (accepted) invitation request to the proper squad leader. + * No direct action causes this message. + * @param player the player who would be joining the squad; + * may or may not have actually requested it in the first place + * @param squad_guid the squad + */ + final case class IndirectInvite(player : Player, squad_guid : PlanetSideGUID) + extends Invitation(player.CharId, player.Name) + + /** + * Utilized in conjunction with an external queuing data structure + * to search for and submit requests to other players + * for the purposes of fill out unoccupied squad roles. + * @param char_id the unique character identifier of the squad leader + * @param name the name of the squad leader + * @param squad_guid the squad + */ + final case class ProximityInvite(char_id : Long, name : String, squad_guid : PlanetSideGUID) + extends Invitation(char_id, name) + + /** + * Utilized in conjunction with an external queuing data structure + * to search for and submit requests to other players + * for the purposes of fill out an unoccupied squad role. + * @param char_id the unique character identifier of the squad leader + * @param name the name of the squad leader + * @param squad_guid the squad with the role + * @param position the index of the role + */ + final case class LookingForSquadRoleInvite(char_id : Long, name : String, squad_guid : PlanetSideGUID, position : Int) + extends Invitation(char_id, name) + + /** + * Utilized when one player issues an invite for some other player for a squad that does not yet exist. + * @param player na + */ + final case class SpontaneousInvite(player : Player) + extends Invitation(player.CharId, player.Name) + + object SquadList { + /** + * Produce complete squad information. + * @see `SquadInfo` + * @param squad the squad + * @return the squad's information to be used in the squad list + */ + def Publish(squad : Squad) : SquadInfo = { + SquadInfo( + squad.Leader.Name, + squad.Task, + PlanetSideZoneID(squad.ZoneId), + squad.Size, + squad.Capacity, + squad.GUID + ) + } + } + + object Detail { + /** + * Produce complete squad membership details. + * @see `SquadDetail` + * @param squad the squad + * @return the squad's information to be used in the squad's detail window + */ + def Publish(squad : Squad) : SquadDetail = { + SquadDetail() + .Field1(squad.GUID.guid) + .LeaderCharId(squad.Leader.CharId) + .LeaderName(squad.Leader.Name) + .Task(squad.Task) + .ZoneId(PlanetSideZoneID(squad.ZoneId)) + .Members( + squad.Membership.zipWithIndex.map({ case (p, index) => + SquadPositionEntry(index, if(squad.Availability(index)) { + SquadPositionDetail(p.Role, p.Orders, p.Requirements, p.CharId, p.Name) + } + else { + SquadPositionDetail.Closed + }) + }).toList + ) + .Complete + } + } + + /** + * Clear the current detail about a squad's membership and replace it with a previously stored details. + * @param squad the squad + * @param favorite the loadout object + */ + def LoadSquadDefinition(squad : Squad, favorite : SquadLoadout) : Unit = { + squad.Task = favorite.task + squad.ZoneId = favorite.zone_id.getOrElse(squad.ZoneId) + squad.Availability.indices.foreach { index => squad.Availability.update(index, false) } + squad.Membership.foreach { position => + position.Role = "" + position.Orders = "" + position.Requirements = Set() + } + favorite.members.foreach { position => + squad.Availability.update(position.index, true) + val member = squad.Membership(position.index) + member.Role = position.role + member.Orders = position.orders + member.Requirements = position.requirements + } + } +} diff --git a/common/src/main/scala/services/teamwork/SquadServiceMessage.scala b/common/src/main/scala/services/teamwork/SquadServiceMessage.scala new file mode 100644 index 000000000..fd856ae02 --- /dev/null +++ b/common/src/main/scala/services/teamwork/SquadServiceMessage.scala @@ -0,0 +1,25 @@ +// Copyright (c) 2019 PSForever +package services.teamwork + +import net.psforever.objects.Player +import net.psforever.objects.zones.Zone +import net.psforever.packet.game.{PlanetSideGUID, SquadAction => PacketSquadAction, WaypointEventAction, WaypointInfo} +import net.psforever.types.{SquadRequestType, SquadWaypoints, Vector3} + +final case class SquadServiceMessage(tplayer : Player, zone : Zone, actionMessage : Any) + +object SquadServiceMessage { + final case class RecoverSquadMembership() +} + +object SquadAction { + sealed trait Action + + final case class InitSquadList() extends Action + final case class InitCharId() extends Action + + final case class Definition(guid : PlanetSideGUID, line : Int, action : PacketSquadAction) extends Action + final case class Membership(request_type : SquadRequestType.Value, unk2 : Long, unk3 : Option[Long], player_name : String, unk5 : Option[Option[String]]) extends Action + final case class Waypoint(event_type : WaypointEventAction.Value, waypoint_type : SquadWaypoints.Value, unk : Option[Long], waypoint_info : Option[WaypointInfo]) extends Action + final case class Update(char_id : Long, health : Int, max_health : Int, armor : Int, max_armor : Int, pos : Vector3, zone_number : Int) extends Action +} diff --git a/common/src/main/scala/services/teamwork/SquadServiceResponse.scala b/common/src/main/scala/services/teamwork/SquadServiceResponse.scala new file mode 100644 index 000000000..82fd13394 --- /dev/null +++ b/common/src/main/scala/services/teamwork/SquadServiceResponse.scala @@ -0,0 +1,45 @@ +// Copyright (c) 2019 PSForever +package services.teamwork + +import net.psforever.objects.teamwork.Squad +import net.psforever.packet.game._ +import net.psforever.types.{SquadResponseType, SquadWaypoints} +import services.GenericEventBusMsg + +final case class SquadServiceResponse(toChannel : String, exclude : Iterable[Long], response : SquadResponse.Response) extends GenericEventBusMsg + +object SquadServiceResponse { + def apply(toChannel : String, response : SquadResponse.Response) : SquadServiceResponse = + SquadServiceResponse(toChannel, Nil, response) + + def apply(toChannel : String, exclude : Long, response : SquadResponse.Response) : SquadServiceResponse = + SquadServiceResponse(toChannel, Seq(exclude), response) +} + +object SquadResponse { + sealed trait Response + + final case class ListSquadFavorite(line : Int, task : String) extends Response + + final case class InitList(info : Vector[SquadInfo]) extends Response + final case class UpdateList(infos : Iterable[(Int, SquadInfo)]) extends Response + final case class RemoveFromList(infos : Iterable[Int]) extends Response + + final case class AssociateWithSquad(squad_guid : PlanetSideGUID) extends Response + final case class SetListSquad(squad_guid : PlanetSideGUID) extends Response + + final case class Membership(request_type : SquadResponseType.Value, unk1 : Int, unk2 : Int, unk3 : Long, unk4 : Option[Long], player_name : String, unk5 : Boolean, unk6 : Option[Option[String]]) extends Response //see SquadMembershipResponse + final case class WantsSquadPosition(leader_char_id : Long, bid_name : String) extends Response + final case class Join(squad : Squad, positionsToUpdate : List[Int], channel : String) extends Response + final case class Leave(squad : Squad, positionsToUpdate : List[(Long, Int)]) extends Response + final case class UpdateMembers(squad : Squad, update_info : List[SquadAction.Update]) extends Response + final case class AssignMember(squad : Squad, from_index : Int, to_index : Int) extends Response + final case class PromoteMember(squad : Squad, char_id : Long, from_index : Int, to_index : Int) extends Response + + final case class Detail(guid : PlanetSideGUID, squad_detail : SquadDetail) extends Response + + final case class InitWaypoints(char_id : Long, waypoints : Iterable[(SquadWaypoints.Value, WaypointInfo, Int)]) extends Response + final case class WaypointEvent(event_type : WaypointEventAction.Value, char_id : Long, waypoint_type : SquadWaypoints.Value, unk5 : Option[Long], waypoint_info : Option[WaypointInfo], unk : Int) extends Response + + final case class SquadSearchResults() extends Response +} diff --git a/common/src/main/scala/services/teamwork/SquadSwitchboard.scala b/common/src/main/scala/services/teamwork/SquadSwitchboard.scala new file mode 100644 index 000000000..165d757c1 --- /dev/null +++ b/common/src/main/scala/services/teamwork/SquadSwitchboard.scala @@ -0,0 +1,142 @@ +// Copyright (c) 2019 PSForever +package services.teamwork + +import akka.actor.{Actor, ActorRef, Terminated} + +import scala.collection.mutable + +/** + * The dedicated messaging switchboard for members and observers of a given squad. + * It almost always dispatches messages to `WorldSessionActor` instances, much like any other `Service`. + * The sole purpose of this `ActorBus` container is to manage a subscription model + * that can involuntarily drop subscribers without informing them explicitly + * or can just vanish without having to properly clean itself up. + */ +class SquadSwitchboard extends Actor { + /** + * This collection contains the message-sending contact reference for squad members. + * Users are added to this collection via the `SquadSwitchboard.Join` message, or a + * combination of the `SquadSwitchboard.DelayJoin` message followed by a + * `SquadSwitchboard.Join` message with or without an `ActorRef` hook. + * The message `SquadSwitchboard.Leave` removes the user from this collection. + * key - unique character id; value - `Actor` reference for that character + */ + val UserActorMap : mutable.LongMap[ActorRef] = mutable.LongMap[ActorRef]() + /** + * This collection contains the message-sending contact information for would-be squad members. + * Users are added to this collection via the `SquadSwitchboard.DelayJoin` message + * and are promoted to an actual squad member through a `SquadSwitchboard.Join` message. + * The message `SquadSwitchboard.Leave` removes the user from this collection. + * key - unique character id; value - `Actor` reference for that character + */ + val DelayedJoin : mutable.LongMap[ActorRef] = mutable.LongMap[ActorRef]() + /** + * This collection contains the message-sending contact information for squad observers. + * Squad observers only get "details" messages as opposed to the sort of messages squad members receive. + * Squad observers are promoted to an actual squad member through a `SquadSwitchboard.Watch` message. + * The message `SquadSwitchboard.Leave` removes the user from this collection. + * The message `SquadSwitchboard.Unwatch` also removes the user from this collection. + * key - unique character id; value - `Actor` reference for that character + */ + val Watchers : mutable.LongMap[ActorRef] = mutable.LongMap[ActorRef]() + + override def postStop() : Unit = { + UserActorMap.clear() + DelayedJoin.clear() + Watchers.clear() + } + + def receive : Receive = { + case SquadSwitchboard.Join(char_id, Some(actor)) => + UserActorMap(char_id) = DelayedJoin.remove(char_id).orElse( Watchers.remove(char_id)) match { + case Some(_actor) => + context.watch(_actor) + _actor + case None => + context.watch(actor) + actor + } + + case SquadSwitchboard.Join(char_id, None) => + DelayedJoin.remove(char_id).orElse( Watchers.remove(char_id)) match { + case Some(actor) => + UserActorMap(char_id) = actor + case None => ; + } + + case SquadSwitchboard.DelayJoin(char_id, actor) => + context.watch(actor) + DelayedJoin(char_id) = actor + + case SquadSwitchboard.Leave(char_id) => + UserActorMap.find { case(charId, _) => charId == char_id } + .orElse(DelayedJoin.find { case(charId, _) => charId == char_id }) + .orElse(Watchers.find { case(charId, _) => charId == char_id }) match { + case Some((member, actor)) => + context.unwatch(actor) + UserActorMap.remove(member) + DelayedJoin.remove(member) + Watchers.remove(member) + case None => ; + } + + case SquadSwitchboard.Watch(char_id, actor) => + context.watch(actor) + Watchers(char_id) = actor + + case SquadSwitchboard.Unwatch(char_id) => + Watchers.remove(char_id) + + case SquadSwitchboard.To(member, msg) => + UserActorMap.find { case (char_id, _) => char_id == member } match { + case Some((_, actor)) => + actor ! msg + case None => ; + } + + case SquadSwitchboard.ToAll(msg) => + UserActorMap + .foreach { case (_, actor) => + actor ! msg + } + + case SquadSwitchboard.Except(excluded, msg) => + UserActorMap + .filterNot { case (char_id, _) => char_id == excluded } + .foreach { case (_, actor) => + actor ! msg + } + + case Terminated(actorRef) => + UserActorMap.find { case(_, ref) => ref == actorRef } + .orElse(DelayedJoin.find { case(_, ref) => ref == actorRef }) + .orElse(Watchers.find { case(_, ref) => ref == actorRef }) match { + case Some((member, actor)) => + context.unwatch(actor) + UserActorMap.remove(member) + DelayedJoin.remove(member) + Watchers.remove(member) + case None => ; + } + + case _ => ; + } +} + +object SquadSwitchboard { + final case class Join(char_id : Long, actor : Option[ActorRef]) + + final case class DelayJoin(char_id : Long, actor : ActorRef) + + final case class Leave(char_id : Long) + + final case class Watch(char_id : Long, actor : ActorRef) + + final case class Unwatch(char_id : Long) + + final case class To(member : Long, msg : SquadServiceResponse) + + final case class ToAll(msg : SquadServiceResponse) + + final case class Except(excluded_member : Long, msg : SquadServiceResponse) +} diff --git a/common/src/test/scala/game/CharacterKnowledgeMessageTest.scala b/common/src/test/scala/game/CharacterKnowledgeMessageTest.scala new file mode 100644 index 000000000..37ba5ad0f --- /dev/null +++ b/common/src/test/scala/game/CharacterKnowledgeMessageTest.scala @@ -0,0 +1,70 @@ +// Copyright (c) 2017 PSForever +package game + +import org.specs2.mutable._ +import net.psforever.packet._ +import net.psforever.packet.game._ +import net.psforever.types.CertificationType +import scodec.bits._ + +class CharacterKnowledgeMessageTest extends Specification { + val string = hex"ec cc637a02 45804600720061006e006b0065006e00740061006e006b0003c022dc0008f01800" + + "decode" in { + PacketCoding.DecodePacket(string).require match { + case CharacterKnowledgeMessage(char_id, Some(info)) => + char_id mustEqual 41575372L + info mustEqual CharacterKnowledgeInfo( + "Frankentank", + Set( + CertificationType.StandardAssault, + CertificationType.ArmoredAssault1, + CertificationType.MediumAssault, + CertificationType.ReinforcedExoSuit, + CertificationType.Harasser, + CertificationType.Engineering, + CertificationType.GroundSupport, + CertificationType.AgileExoSuit, + CertificationType.AIMAX, + CertificationType.StandardExoSuit, + CertificationType.AAMAX, + CertificationType.ArmoredAssault2 + ), + 15, + 0, + PlanetSideGUID(12) + ) + case _ => + ko + } + } + + "encode" in { + val msg = CharacterKnowledgeMessage( + 41575372L, + CharacterKnowledgeInfo( + "Frankentank", + Set( + CertificationType.StandardAssault, + CertificationType.ArmoredAssault1, + CertificationType.MediumAssault, + CertificationType.ReinforcedExoSuit, + CertificationType.Harasser, + CertificationType.Engineering, + CertificationType.GroundSupport, + CertificationType.AgileExoSuit, + CertificationType.AIMAX, + CertificationType.StandardExoSuit, + CertificationType.AAMAX, + CertificationType.ArmoredAssault2 + ), + 15, + 0, + PlanetSideGUID(12) + ) + ) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string + } +} diff --git a/common/src/test/scala/game/ReplicationStreamMessageTest.scala b/common/src/test/scala/game/ReplicationStreamMessageTest.scala index d878b9374..2d39a2a63 100644 --- a/common/src/test/scala/game/ReplicationStreamMessageTest.scala +++ b/common/src/test/scala/game/ReplicationStreamMessageTest.scala @@ -19,17 +19,7 @@ class ReplicationStreamMessageTest extends Specification { val stringUpdateLeaderSize = hex"E6 C0 58 10 C3 00 4A0069006D006D0079006E00 43 FF" val stringUpdateTaskContinent = hex"E6 C0 58 11 40 80 3200 3 04000000 FF0" val stringUpdateAll = hex"E6 C0 78 30 58 0430 6D00610064006D0075006A00 80 040000000A FF" - //failing conditions - val stringCodecFail = hex"E6 20 A1 19 FE" - val stringListOneFail = hex"E6 B8 01 06 01 00 8B 46007200610067004C0041004E00640049004E004300 84 4600720061006700 0A00 00 01 0A FF" - val stringListTwoFail = hex"E6 B8 01 06 06 00 8E 470065006E006500720061006C0047006F0072006700750074007A00 A1 46004C0059002C0041006C006C002000770065006C0063006F006D0065002C0063006E0020006C0061007300740020006E0069006700680074002100210021002100 0400 00 00 7A 01 83 02 00 45 80 4B004F004B006B006900610073004D00460043004E00 87 5300710075006100640020003200 0400 00 01 6A FF" - val stringUpdateLeaderFail = hex"E6 C0 28 08 44 00 46006100740065004A0048004E004300 FF" - val stringUpdateTaskFail = hex"E6 C0 58 09CE00 52004900500020005000530031002C0020007600690073006900740020005000530046006F00720065007600650072002E006E0065007400 FF" - val stringUpdateContinentFail = hex"E6 C0 38 09 85000001 7F80" - val stringUpdateSizeFail = hex"E6 C0 18 0A B7 F8" - val stringUpdateLeaderSizeFail = hex"E6 C0 58 10 43 00 4A0069006D006D0079006E00 43 FF" - val stringUpdateTaskContinentFail = hex"E6 C0 58 11 C0 80 3200 3 04000000 FF0" - val stringUpdateAllFail = hex"E6 C0 78 30 58 0430 6D00610064006D0075006A00 80 04000001 0A FF" + val stringRemoveUpdate = hex"e6 20201801014aff" "SquadInfo (w/out squad_guid)" in { val o = SquadInfo("FragLANdINC", "Frag", PlanetSideZoneID(10), 0, 10) @@ -56,15 +46,27 @@ class ReplicationStreamMessageTest extends Specification { o.capacity.get mustEqual 7 } + "SquadInfo (Add)" in { + val o1 = SquadInfo(Some("FragLANdINC"), Some("Frag"), Some(PlanetSideZoneID(10)), None, None) + val o2 = SquadInfo(Some(7), 10) + val o3 = SquadInfo("FragLANdINC", "Frag", PlanetSideZoneID(10), 7, 10) + o1.And(o2) mustEqual o3 + } + + "SquadInfo (Add, with blocked fields)" in { + val o1 = SquadInfo(Some("FragLANdINC"), None, Some(PlanetSideZoneID(10)), None, Some(10)) + val o2 = SquadInfo(Some("Frag"), Some("Frag"), Some(PlanetSideZoneID(15)), Some(7), Some(7)) + val o3 = SquadInfo("FragLANdINC", "Frag", PlanetSideZoneID(10), 7, 10) + o1.And(o2) mustEqual o3 + } + "decode (clear)" in { PacketCoding.DecodePacket(stringListClear).require match { case ReplicationStreamMessage(behavior, behavior2, entries) => behavior mustEqual 5 behavior2.isDefined mustEqual true behavior2.get mustEqual 6 - entries.length mustEqual 1 - entries.head.index mustEqual 255 - entries.head.listing.isDefined mustEqual false + entries.length mustEqual 0 case _ => ko } @@ -75,27 +77,21 @@ class ReplicationStreamMessageTest extends Specification { case ReplicationStreamMessage(behavior, behavior2, entries) => behavior mustEqual 5 behavior2.get mustEqual 6 - entries.length mustEqual 2 + entries.length mustEqual 1 entries.head.index mustEqual 0 entries.head.listing.isDefined mustEqual true - entries.head.listing.get.unk1 mustEqual 131 - entries.head.listing.get.unk2 mustEqual false - entries.head.listing.get.unk3.isDefined mustEqual false - entries.head.listing.get.info.isDefined mustEqual true - entries.head.listing.get.info.get.leader.isDefined mustEqual true - entries.head.listing.get.info.get.leader.get mustEqual "FragLANdINC" - entries.head.listing.get.info.get.task.isDefined mustEqual true - entries.head.listing.get.info.get.task.get mustEqual "Frag" - entries.head.listing.get.info.get.zone_id.isDefined mustEqual true - entries.head.listing.get.info.get.zone_id.get mustEqual PlanetSideZoneID(10) - entries.head.listing.get.info.get.size.isDefined mustEqual true - entries.head.listing.get.info.get.size.get mustEqual 0 - entries.head.listing.get.info.get.capacity.isDefined mustEqual true - entries.head.listing.get.info.get.capacity.get mustEqual 10 - entries.head.listing.get.info.get.squad_guid.isDefined mustEqual true - entries.head.listing.get.info.get.squad_guid.get mustEqual PlanetSideGUID(1) - entries(1).index mustEqual 255 - entries(1).listing.isDefined mustEqual false + entries.head.listing.get.leader.isDefined mustEqual true + entries.head.listing.get.leader.get mustEqual "FragLANdINC" + entries.head.listing.get.task.isDefined mustEqual true + entries.head.listing.get.task.get mustEqual "Frag" + entries.head.listing.get.zone_id.isDefined mustEqual true + entries.head.listing.get.zone_id.get mustEqual PlanetSideZoneID(10) + entries.head.listing.get.size.isDefined mustEqual true + entries.head.listing.get.size.get mustEqual 0 + entries.head.listing.get.capacity.isDefined mustEqual true + entries.head.listing.get.capacity.get mustEqual 10 + entries.head.listing.get.squad_guid.isDefined mustEqual true + entries.head.listing.get.squad_guid.get mustEqual PlanetSideGUID(1) case _ => ko } @@ -106,28 +102,21 @@ class ReplicationStreamMessageTest extends Specification { case ReplicationStreamMessage(behavior, behavior2, entries) => behavior mustEqual 5 behavior2.get mustEqual 6 - entries.length mustEqual 3 + entries.length mustEqual 2 entries.head.index mustEqual 0 - entries.head.listing.get.unk1 mustEqual 131 - entries.head.listing.get.unk2 mustEqual false - entries.head.listing.get.unk3.isDefined mustEqual false - entries.head.listing.get.info.get.leader.get mustEqual "GeneralGorgutz" - entries.head.listing.get.info.get.task.get mustEqual "FLY,All welcome,cn last night!!!!" - entries.head.listing.get.info.get.zone_id.get mustEqual PlanetSideZoneID(4) - entries.head.listing.get.info.get.size.get mustEqual 7 - entries.head.listing.get.info.get.capacity.get mustEqual 10 - entries.head.listing.get.info.get.squad_guid.get mustEqual PlanetSideGUID(6) + entries.head.listing.get.leader.get mustEqual "GeneralGorgutz" + entries.head.listing.get.task.get mustEqual "FLY,All welcome,cn last night!!!!" + entries.head.listing.get.zone_id.get mustEqual PlanetSideZoneID(4) + entries.head.listing.get.size.get mustEqual 7 + entries.head.listing.get.capacity.get mustEqual 10 + entries.head.listing.get.squad_guid.get mustEqual PlanetSideGUID(6) entries(1).index mustEqual 1 - entries(1).listing.get.unk1 mustEqual 131 - entries(1).listing.get.unk2 mustEqual false - entries(1).listing.get.unk3.isDefined mustEqual false - entries(1).listing.get.info.get.leader.get mustEqual "KOKkiasMFCN" - entries(1).listing.get.info.get.task.get mustEqual "Squad 2" - entries(1).listing.get.info.get.zone_id.get mustEqual PlanetSideZoneID(4) - entries(1).listing.get.info.get.size.get mustEqual 6 - entries(1).listing.get.info.get.capacity.get mustEqual 10 - entries(1).listing.get.info.get.squad_guid.get mustEqual PlanetSideGUID(4) - entries(2).index mustEqual 255 + entries(1).listing.get.leader.get mustEqual "KOKkiasMFCN" + entries(1).listing.get.task.get mustEqual "Squad 2" + entries(1).listing.get.zone_id.get mustEqual PlanetSideZoneID(4) + entries(1).listing.get.size.get mustEqual 6 + entries(1).listing.get.capacity.get mustEqual 10 + entries(1).listing.get.squad_guid.get mustEqual PlanetSideGUID(4) case _ => ko } @@ -138,38 +127,28 @@ class ReplicationStreamMessageTest extends Specification { case ReplicationStreamMessage(behavior, behavior2, entries) => behavior mustEqual 5 behavior2.get mustEqual 6 - entries.length mustEqual 4 + entries.length mustEqual 3 entries.head.index mustEqual 0 - entries.head.listing.get.unk1 mustEqual 131 - entries.head.listing.get.unk2 mustEqual false - entries.head.listing.get.unk3.isDefined mustEqual false - entries.head.listing.get.info.get.leader.get mustEqual "GeneralGorgutz" - entries.head.listing.get.info.get.task.get mustEqual "FLY,All welcome,cn last night!!!!" - entries.head.listing.get.info.get.zone_id.get mustEqual PlanetSideZoneID(4) - entries.head.listing.get.info.get.size.get mustEqual 7 - entries.head.listing.get.info.get.capacity.get mustEqual 10 - entries.head.listing.get.info.get.squad_guid.get mustEqual PlanetSideGUID(6) + entries.head.listing.get.leader.get mustEqual "GeneralGorgutz" + entries.head.listing.get.task.get mustEqual "FLY,All welcome,cn last night!!!!" + entries.head.listing.get.zone_id.get mustEqual PlanetSideZoneID(4) + entries.head.listing.get.size.get mustEqual 7 + entries.head.listing.get.capacity.get mustEqual 10 + entries.head.listing.get.squad_guid.get mustEqual PlanetSideGUID(6) entries(1).index mustEqual 1 - entries(1).listing.get.unk1 mustEqual 131 - entries(1).listing.get.unk2 mustEqual false - entries(1).listing.get.unk3.isDefined mustEqual false - entries(1).listing.get.info.get.leader.get mustEqual "NIGHT88RAVEN" - entries(1).listing.get.info.get.task.get mustEqual "All Welcome" - entries(1).listing.get.info.get.zone_id.get mustEqual PlanetSideZoneID(10) - entries(1).listing.get.info.get.size.get mustEqual 4 - entries(1).listing.get.info.get.capacity.get mustEqual 10 - entries(1).listing.get.info.get.squad_guid.get mustEqual PlanetSideGUID(3) + entries(1).listing.get.leader.get mustEqual "NIGHT88RAVEN" + entries(1).listing.get.task.get mustEqual "All Welcome" + entries(1).listing.get.zone_id.get mustEqual PlanetSideZoneID(10) + entries(1).listing.get.size.get mustEqual 4 + entries(1).listing.get.capacity.get mustEqual 10 + entries(1).listing.get.squad_guid.get mustEqual PlanetSideGUID(3) entries(2).index mustEqual 2 - entries(2).listing.get.unk1 mustEqual 131 - entries(2).listing.get.unk2 mustEqual false - entries(2).listing.get.unk3.isDefined mustEqual false - entries(2).listing.get.info.get.leader.get mustEqual "KOKkiasMFCN" - entries(2).listing.get.info.get.task.get mustEqual "Squad 2" - entries(2).listing.get.info.get.zone_id.get mustEqual PlanetSideZoneID(4) - entries(2).listing.get.info.get.size.get mustEqual 6 - entries(2).listing.get.info.get.capacity.get mustEqual 10 - entries(2).listing.get.info.get.squad_guid.get mustEqual PlanetSideGUID(4) - entries(3).index mustEqual 255 + entries(2).listing.get.leader.get mustEqual "KOKkiasMFCN" + entries(2).listing.get.task.get mustEqual "Squad 2" + entries(2).listing.get.zone_id.get mustEqual PlanetSideZoneID(4) + entries(2).listing.get.size.get mustEqual 6 + entries(2).listing.get.capacity.get mustEqual 10 + entries(2).listing.get.squad_guid.get mustEqual PlanetSideGUID(4) case _ => ko } @@ -180,15 +159,9 @@ class ReplicationStreamMessageTest extends Specification { case ReplicationStreamMessage(behavior, behavior2, entries) => behavior mustEqual 1 behavior2.isDefined mustEqual false - entries.length mustEqual 2 + entries.length mustEqual 1 entries.head.index mustEqual 5 - entries.head.listing.isDefined mustEqual true - entries.head.listing.get.unk1 mustEqual 0 - entries.head.listing.get.unk2 mustEqual true - entries.head.listing.get.unk3.isDefined mustEqual true - entries.head.listing.get.unk3.get mustEqual 4 - entries.head.listing.get.info.isDefined mustEqual false - entries(1).index mustEqual 255 + entries.head.listing.isDefined mustEqual false case _ => ko } @@ -199,22 +172,16 @@ class ReplicationStreamMessageTest extends Specification { case ReplicationStreamMessage(behavior, behavior2, entries) => behavior mustEqual 6 behavior2.isDefined mustEqual false - entries.length mustEqual 2 + entries.length mustEqual 1 entries.head.index mustEqual 2 entries.head.listing.isDefined mustEqual true - entries.head.listing.get.unk1 mustEqual 128 - entries.head.listing.get.unk2 mustEqual true - entries.head.listing.get.unk3.isDefined mustEqual true - entries.head.listing.get.unk3.get mustEqual 0 - entries.head.listing.get.info.isDefined mustEqual true - entries.head.listing.get.info.get.leader.isDefined mustEqual true - entries.head.listing.get.info.get.leader.get mustEqual "FateJHNC" - entries.head.listing.get.info.get.task.isDefined mustEqual false - entries.head.listing.get.info.get.zone_id.isDefined mustEqual false - entries.head.listing.get.info.get.size.isDefined mustEqual false - entries.head.listing.get.info.get.capacity.isDefined mustEqual false - entries.head.listing.get.info.get.squad_guid.isDefined mustEqual false - entries(1).index mustEqual 255 + entries.head.listing.get.leader.isDefined mustEqual true + entries.head.listing.get.leader.get mustEqual "FateJHNC" + entries.head.listing.get.task.isDefined mustEqual false + entries.head.listing.get.zone_id.isDefined mustEqual false + entries.head.listing.get.size.isDefined mustEqual false + entries.head.listing.get.capacity.isDefined mustEqual false + entries.head.listing.get.squad_guid.isDefined mustEqual false case _ => ko } @@ -225,22 +192,16 @@ class ReplicationStreamMessageTest extends Specification { case ReplicationStreamMessage(behavior, behavior2, entries) => behavior mustEqual 6 behavior2.isDefined mustEqual false - entries.length mustEqual 2 + entries.length mustEqual 1 entries.head.index mustEqual 5 entries.head.listing.isDefined mustEqual true - entries.head.listing.get.unk1 mustEqual 128 - entries.head.listing.get.unk2 mustEqual true - entries.head.listing.get.unk3.isDefined mustEqual true - entries.head.listing.get.unk3.get mustEqual 1 - entries.head.listing.get.info.isDefined mustEqual true - entries.head.listing.get.info.get.leader.isDefined mustEqual false - entries.head.listing.get.info.get.task.isDefined mustEqual true - entries.head.listing.get.info.get.task.get mustEqual "RIP PS1, visit PSForever.net" - entries.head.listing.get.info.get.zone_id.isDefined mustEqual false - entries.head.listing.get.info.get.size.isDefined mustEqual false - entries.head.listing.get.info.get.capacity.isDefined mustEqual false - entries.head.listing.get.info.get.squad_guid.isDefined mustEqual false - entries(1).index mustEqual 255 + entries.head.listing.get.leader.isDefined mustEqual false + entries.head.listing.get.task.isDefined mustEqual true + entries.head.listing.get.task.get mustEqual "RIP PS1, visit PSForever.net" + entries.head.listing.get.zone_id.isDefined mustEqual false + entries.head.listing.get.size.isDefined mustEqual false + entries.head.listing.get.capacity.isDefined mustEqual false + entries.head.listing.get.squad_guid.isDefined mustEqual false case _ => ko } @@ -251,22 +212,16 @@ class ReplicationStreamMessageTest extends Specification { case ReplicationStreamMessage(behavior, behavior2, entries) => behavior mustEqual 6 behavior2.isDefined mustEqual false - entries.length mustEqual 2 + entries.length mustEqual 1 entries.head.index mustEqual 3 entries.head.listing.isDefined mustEqual true - entries.head.listing.get.unk1 mustEqual 128 - entries.head.listing.get.unk2 mustEqual true - entries.head.listing.get.unk3.isDefined mustEqual true - entries.head.listing.get.unk3.get mustEqual 1 - entries.head.listing.get.info.isDefined mustEqual true - entries.head.listing.get.info.get.leader.isDefined mustEqual false - entries.head.listing.get.info.get.task.isDefined mustEqual false - entries.head.listing.get.info.get.zone_id.isDefined mustEqual true - entries.head.listing.get.info.get.zone_id.get mustEqual PlanetSideZoneID(10) - entries.head.listing.get.info.get.size.isDefined mustEqual false - entries.head.listing.get.info.get.capacity.isDefined mustEqual false - entries.head.listing.get.info.get.squad_guid.isDefined mustEqual false - entries(1).index mustEqual 255 + entries.head.listing.get.leader.isDefined mustEqual false + entries.head.listing.get.task.isDefined mustEqual false + entries.head.listing.get.zone_id.isDefined mustEqual true + entries.head.listing.get.zone_id.get mustEqual PlanetSideZoneID(10) + entries.head.listing.get.size.isDefined mustEqual false + entries.head.listing.get.capacity.isDefined mustEqual false + entries.head.listing.get.squad_guid.isDefined mustEqual false case _ => ko } @@ -277,22 +232,16 @@ class ReplicationStreamMessageTest extends Specification { case ReplicationStreamMessage(behavior, behavior2, entries) => behavior mustEqual 6 behavior2.isDefined mustEqual false - entries.length mustEqual 2 + entries.length mustEqual 1 entries.head.index mustEqual 1 entries.head.listing.isDefined mustEqual true - entries.head.listing.get.unk1 mustEqual 128 - entries.head.listing.get.unk2 mustEqual true - entries.head.listing.get.unk3.isDefined mustEqual true - entries.head.listing.get.unk3.get mustEqual 2 - entries.head.listing.get.info.isDefined mustEqual true - entries.head.listing.get.info.get.leader.isDefined mustEqual false - entries.head.listing.get.info.get.task.isDefined mustEqual false - entries.head.listing.get.info.get.zone_id.isDefined mustEqual false - entries.head.listing.get.info.get.size.isDefined mustEqual true - entries.head.listing.get.info.get.size.get mustEqual 6 - entries.head.listing.get.info.get.capacity.isDefined mustEqual false - entries.head.listing.get.info.get.squad_guid.isDefined mustEqual false - entries(1).index mustEqual 255 + entries.head.listing.get.leader.isDefined mustEqual false + entries.head.listing.get.task.isDefined mustEqual false + entries.head.listing.get.zone_id.isDefined mustEqual false + entries.head.listing.get.size.isDefined mustEqual true + entries.head.listing.get.size.get mustEqual 6 + entries.head.listing.get.capacity.isDefined mustEqual false + entries.head.listing.get.squad_guid.isDefined mustEqual false case _ => ko } @@ -303,23 +252,17 @@ class ReplicationStreamMessageTest extends Specification { case ReplicationStreamMessage(behavior, behavior2, entries) => behavior mustEqual 6 behavior2.isDefined mustEqual false - entries.length mustEqual 2 + entries.length mustEqual 1 entries.head.index mustEqual 5 entries.head.listing.isDefined mustEqual true - entries.head.listing.get.unk1 mustEqual 129 - entries.head.listing.get.unk2 mustEqual false - entries.head.listing.get.unk3.isDefined mustEqual true - entries.head.listing.get.unk3.get mustEqual 0 - entries.head.listing.get.info.isDefined mustEqual true - entries.head.listing.get.info.get.leader.isDefined mustEqual true - entries.head.listing.get.info.get.leader.get mustEqual "Jimmyn" - entries.head.listing.get.info.get.task.isDefined mustEqual false - entries.head.listing.get.info.get.zone_id.isDefined mustEqual false - entries.head.listing.get.info.get.size.isDefined mustEqual true - entries.head.listing.get.info.get.size.get mustEqual 3 - entries.head.listing.get.info.get.capacity.isDefined mustEqual false - entries.head.listing.get.info.get.squad_guid.isDefined mustEqual false - entries(1).index mustEqual 255 + entries.head.listing.get.leader.isDefined mustEqual true + entries.head.listing.get.leader.get mustEqual "Jimmyn" + entries.head.listing.get.task.isDefined mustEqual false + entries.head.listing.get.zone_id.isDefined mustEqual false + entries.head.listing.get.size.isDefined mustEqual true + entries.head.listing.get.size.get mustEqual 3 + entries.head.listing.get.capacity.isDefined mustEqual false + entries.head.listing.get.squad_guid.isDefined mustEqual false case _ => ko } @@ -330,23 +273,17 @@ class ReplicationStreamMessageTest extends Specification { case ReplicationStreamMessage(behavior, behavior2, entries) => behavior mustEqual 6 behavior2.isDefined mustEqual false - entries.length mustEqual 2 + entries.length mustEqual 1 entries.head.index mustEqual 5 entries.head.listing.isDefined mustEqual true - entries.head.listing.get.unk1 mustEqual 129 - entries.head.listing.get.unk2 mustEqual false - entries.head.listing.get.unk3.isDefined mustEqual true - entries.head.listing.get.unk3.get mustEqual 1 - entries.head.listing.get.info.isDefined mustEqual true - entries.head.listing.get.info.get.leader.isDefined mustEqual false - entries.head.listing.get.info.get.task.isDefined mustEqual true - entries.head.listing.get.info.get.task.get mustEqual "2" - entries.head.listing.get.info.get.zone_id.isDefined mustEqual true - entries.head.listing.get.info.get.zone_id.get mustEqual PlanetSideZoneID(4) - entries.head.listing.get.info.get.size.isDefined mustEqual false - entries.head.listing.get.info.get.capacity.isDefined mustEqual false - entries.head.listing.get.info.get.squad_guid.isDefined mustEqual false - entries(1).index mustEqual 255 + entries.head.listing.get.leader.isDefined mustEqual false + entries.head.listing.get.task.isDefined mustEqual true + entries.head.listing.get.task.get mustEqual "2" + entries.head.listing.get.zone_id.isDefined mustEqual true + entries.head.listing.get.zone_id.get mustEqual PlanetSideZoneID(4) + entries.head.listing.get.size.isDefined mustEqual false + entries.head.listing.get.capacity.isDefined mustEqual false + entries.head.listing.get.squad_guid.isDefined mustEqual false case _ => ko } @@ -357,60 +294,57 @@ class ReplicationStreamMessageTest extends Specification { case ReplicationStreamMessage(behavior, behavior2, entries) => behavior mustEqual 6 behavior2.isDefined mustEqual false - entries.length mustEqual 2 + entries.length mustEqual 1 entries.head.index mustEqual 7 entries.head.listing.isDefined mustEqual true - entries.head.listing.get.unk1 mustEqual 131 - entries.head.listing.get.unk2 mustEqual false - entries.head.listing.get.unk3.isDefined mustEqual false - entries.head.listing.get.info.isDefined mustEqual true - entries.head.listing.get.info.get.leader.isDefined mustEqual true - entries.head.listing.get.info.get.leader.get mustEqual "madmuj" - entries.head.listing.get.info.get.task.isDefined mustEqual true - entries.head.listing.get.info.get.task.get mustEqual "" - entries.head.listing.get.info.get.zone_id.isDefined mustEqual true - entries.head.listing.get.info.get.zone_id.get mustEqual PlanetSideZoneID(4) - entries.head.listing.get.info.get.size.isDefined mustEqual true - entries.head.listing.get.info.get.size.get mustEqual 0 - entries.head.listing.get.info.get.capacity.isDefined mustEqual true - entries.head.listing.get.info.get.capacity.get mustEqual 10 - entries.head.listing.get.info.get.squad_guid.isDefined mustEqual true - entries.head.listing.get.info.get.squad_guid.get mustEqual PlanetSideGUID(11) - entries(1).index mustEqual 255 + entries.head.listing.get.leader.isDefined mustEqual true + entries.head.listing.get.leader.get mustEqual "madmuj" + entries.head.listing.get.task.isDefined mustEqual true + entries.head.listing.get.task.get mustEqual "" + entries.head.listing.get.zone_id.isDefined mustEqual true + entries.head.listing.get.zone_id.get mustEqual PlanetSideZoneID(4) + entries.head.listing.get.size.isDefined mustEqual true + entries.head.listing.get.size.get mustEqual 0 + entries.head.listing.get.capacity.isDefined mustEqual true + entries.head.listing.get.capacity.get mustEqual 10 + entries.head.listing.get.squad_guid.isDefined mustEqual true + entries.head.listing.get.squad_guid.get mustEqual PlanetSideGUID(11) case _ => ko } } - "decode (fails)" in { - PacketCoding.DecodePacket(stringCodecFail).isFailure mustEqual true - //PacketCoding.DecodePacket(stringListOneFail).isFailure mustEqual true -> used to fail - //PacketCoding.DecodePacket(stringListTwoFail).isFailure mustEqual true -> used to fail - PacketCoding.DecodePacket(stringUpdateLeaderFail).isFailure mustEqual true - PacketCoding.DecodePacket(stringUpdateTaskFail).isFailure mustEqual true - //PacketCoding.DecodePacket(stringUpdateContinentFail).isFailure mustEqual true -> used to fail - PacketCoding.DecodePacket(stringUpdateSizeFail).isFailure mustEqual true - PacketCoding.DecodePacket(stringUpdateLeaderSizeFail).isFailure mustEqual true - PacketCoding.DecodePacket(stringUpdateTaskContinentFail).isFailure mustEqual true - //PacketCoding.DecodePacket(stringUpdateAllFail).isFailure mustEqual true -> used to fail + "decode (remove 1 and update 0)" in { + PacketCoding.DecodePacket(stringRemoveUpdate).require match { + case ReplicationStreamMessage(behavior, behavior2, entries) => + behavior mustEqual 1 + behavior2.isDefined mustEqual false + entries.length mustEqual 2 + entries.head.index mustEqual 1 + entries.head.listing.isDefined mustEqual false + entries(1).listing.get.leader.isDefined mustEqual false + entries(1).listing.get.task.isDefined mustEqual false + entries(1).listing.get.zone_id.isDefined mustEqual false + entries(1).listing.get.size.isDefined mustEqual true + entries(1).listing.get.size.get mustEqual 10 + entries(1).listing.get.capacity.isDefined mustEqual false + entries(1).listing.get.squad_guid.isDefined mustEqual false + case _ => + ko + } } "encode (clear)" in { - val msg = ReplicationStreamMessage(5, Some(6), - Vector( - SquadListing(255) - ) - ) + val msg = ReplicationStreamMessage(5, Some(6), Vector.empty) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual stringListClear } "encode (one)" in { - val msg = ReplicationStreamMessage(5, Some(6), - Vector( - SquadListing(0, Some(SquadHeader(131, false, None, SquadInfo("FragLANdINC", "Frag", PlanetSideZoneID(10), 0, 10, PlanetSideGUID(1))))), - SquadListing(255) + val msg = ReplicationStreamMessage( + Seq( + SquadInfo("FragLANdINC", "Frag", PlanetSideZoneID(10), 0, 10, PlanetSideGUID(1)) ) ) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector @@ -419,11 +353,10 @@ class ReplicationStreamMessageTest extends Specification { } "encode (two)" in { - val msg = ReplicationStreamMessage(5, Some(6), - Vector( - SquadListing(0, Some(SquadHeader(131, false, None, SquadInfo("GeneralGorgutz", "FLY,All welcome,cn last night!!!!", PlanetSideZoneID(4), 7, 10, PlanetSideGUID(6))))), - SquadListing(1, Some(SquadHeader(131, false, None, SquadInfo("KOKkiasMFCN", "Squad 2", PlanetSideZoneID(4), 6, 10, PlanetSideGUID(4))))), - SquadListing(255) + val msg = ReplicationStreamMessage( + Seq( + SquadInfo("GeneralGorgutz", "FLY,All welcome,cn last night!!!!", PlanetSideZoneID(4), 7, 10, PlanetSideGUID(6)), + SquadInfo("KOKkiasMFCN", "Squad 2", PlanetSideZoneID(4), 6, 10, PlanetSideGUID(4)) ) ) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector @@ -432,12 +365,11 @@ class ReplicationStreamMessageTest extends Specification { } "encode (three)" in { - val msg = ReplicationStreamMessage(5, Some(6), - Vector( - SquadListing(0, Some(SquadHeader(131, false, None, SquadInfo("GeneralGorgutz", "FLY,All welcome,cn last night!!!!", PlanetSideZoneID(4), 7, 10, PlanetSideGUID(6))))), - SquadListing(1, Some(SquadHeader(131, false, None, SquadInfo("NIGHT88RAVEN", "All Welcome", PlanetSideZoneID(10), 4, 10, PlanetSideGUID(3))))), - SquadListing(2, Some(SquadHeader(131, false, None, SquadInfo("KOKkiasMFCN", "Squad 2", PlanetSideZoneID(4), 6, 10, PlanetSideGUID(4))))), - SquadListing(255) + val msg = ReplicationStreamMessage( + Seq( + SquadInfo("GeneralGorgutz", "FLY,All welcome,cn last night!!!!", PlanetSideZoneID(4), 7, 10, PlanetSideGUID(6)), + SquadInfo("NIGHT88RAVEN", "All Welcome", PlanetSideZoneID(10), 4, 10, PlanetSideGUID(3)), + SquadInfo("KOKkiasMFCN", "Squad 2", PlanetSideZoneID(4), 6, 10, PlanetSideGUID(4)) ) ) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector @@ -448,8 +380,7 @@ class ReplicationStreamMessageTest extends Specification { "encode (remove)" in { val msg = ReplicationStreamMessage(1, None, Vector( - SquadListing(5, Some(SquadHeader(0, true, Some(4)))), - SquadListing(255) + SquadListing(5, None) ) ) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector @@ -460,8 +391,7 @@ class ReplicationStreamMessageTest extends Specification { "encode (update leader)" in { val msg = ReplicationStreamMessage(6, None, Vector( - SquadListing(2, Some(SquadHeader(128, true, Some(0), SquadInfo("FateJHNC", None)))), - SquadListing(255) + SquadListing(2, SquadInfo("FateJHNC")) ) ) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector @@ -472,8 +402,7 @@ class ReplicationStreamMessageTest extends Specification { "encode (update task)" in { val msg = ReplicationStreamMessage(6, None, Vector( - SquadListing(5, Some(SquadHeader(128, true, Some(1), SquadInfo(None, "RIP PS1, visit PSForever.net")))), - SquadListing(255) + SquadListing(5, SquadInfo(None, "RIP PS1, visit PSForever.net")) ) ) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector @@ -484,8 +413,7 @@ class ReplicationStreamMessageTest extends Specification { "encode (update continent)" in { val msg = ReplicationStreamMessage(6, None, Vector( - SquadListing(3, Some(SquadHeader(128, true, Some(1), SquadInfo(PlanetSideZoneID(10))))), - SquadListing(255) + SquadListing(3, SquadInfo(PlanetSideZoneID(10))) ) ) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector @@ -496,8 +424,7 @@ class ReplicationStreamMessageTest extends Specification { "encode (update size)" in { val msg = ReplicationStreamMessage(6, None, Vector( - SquadListing(1, Some(SquadHeader(128, true, Some(2), SquadInfo(6, None)))), - SquadListing(255) + SquadListing(1, SquadInfo(6)) ) ) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector @@ -508,8 +435,7 @@ class ReplicationStreamMessageTest extends Specification { "encode (update leader and size)" in { val msg = ReplicationStreamMessage(6, None, Vector( - SquadListing(5, Some(SquadHeader(129, false, Some(0), SquadInfo("Jimmyn", 3)))), - SquadListing(255) + SquadListing(5, SquadInfo("Jimmyn").And(SquadInfo(3))) ) ) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector @@ -520,8 +446,7 @@ class ReplicationStreamMessageTest extends Specification { "encode (update task and continent)" in { val msg = ReplicationStreamMessage(6, None, Vector( - SquadListing(5, Some(SquadHeader(129, false, Some(1), SquadInfo("2", PlanetSideZoneID(4))))), - SquadListing(255) + SquadListing(5, SquadInfo(None, "2").And(SquadInfo(PlanetSideZoneID(4)))) ) ) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector @@ -532,8 +457,7 @@ class ReplicationStreamMessageTest extends Specification { "encode (update all)" in { val msg = ReplicationStreamMessage(6, None, Vector( - SquadListing(7, Some(SquadHeader(131, false, None, SquadInfo("madmuj", "", PlanetSideZoneID(4), 0, 10, PlanetSideGUID(11))))), - SquadListing(255) + SquadListing(7, SquadInfo("madmuj", "", PlanetSideZoneID(4), 0, 10, PlanetSideGUID(11))) ) ) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector @@ -541,116 +465,15 @@ class ReplicationStreamMessageTest extends Specification { pkt mustEqual stringUpdateAll } - "encode (fails)" in { - //encode codec fail - PacketCoding.EncodePacket( - ReplicationStreamMessage(1, None, - Vector( - SquadListing(5, Some(SquadHeader(0, false, Some(4)))), - SquadListing(255) - ) + "encode (remove 1 and update 0)" in { + val msg = ReplicationStreamMessage(1, None, + Vector( + SquadListing(1, None), + SquadListing(0, SquadInfo(10)) ) - ).isFailure mustEqual true + ) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector - //encode one - PacketCoding.EncodePacket( - ReplicationStreamMessage(5, Some(6), - Vector( - SquadListing(0, Some(SquadHeader(131, false, None, Some(SquadInfo(Some("FragLANdINC"), Some("Frag"), None, Some(0),Some(10), Some(PlanetSideGUID(1))))))), - SquadListing(255) - ) - ) - ).isFailure mustEqual true - - //encode two - PacketCoding.EncodePacket( - ReplicationStreamMessage(5, Some(6), - Vector( - SquadListing(0, Some(SquadHeader(131, false, None, SquadInfo("GeneralGorgutz", "FLY,All welcome,cn last night!!!!", PlanetSideZoneID(4), 7, 10, PlanetSideGUID(6))))), - SquadListing(1, Some(SquadHeader(131, false, None, Some(SquadInfo(Some("KOKkiasMFCN"), Some("Squad 2"), None, Some(6), Some(10), Some(PlanetSideGUID(4))))))), - SquadListing(255) - ) - ) - ).isFailure mustEqual true - - //encode leader - PacketCoding.EncodePacket( - ReplicationStreamMessage(6, None, - Vector( - SquadListing(2, Some(SquadHeader(128, true, Some(0), Some(SquadInfo(None, None, None, None, None, None))))), - SquadListing(255) - ) - ) - ).isFailure mustEqual true - - //encode task - PacketCoding.EncodePacket( - ReplicationStreamMessage(6, None, - Vector( - SquadListing(5, Some(SquadHeader(128, true, Some(1), Some(SquadInfo(None, None, None, None, None, None))))), - SquadListing(255) - ) - ) - ).isFailure mustEqual true - - //encode continent - PacketCoding.EncodePacket( - ReplicationStreamMessage(6, None, - Vector( - SquadListing(3, Some(SquadHeader(128, true, Some(1), Some(SquadInfo(None, None, None, None, None, None))))), - SquadListing(255) - ) - ) - ).isFailure mustEqual true - - //encode task or continent - PacketCoding.EncodePacket( - ReplicationStreamMessage(6, None, - Vector( - SquadListing(3, Some(SquadHeader(128, true, Some(1), Some(SquadInfo(None, Some(""), Some(PlanetSideZoneID(10)), None, None, None))))), - SquadListing(255) - ) - ) - ).isFailure mustEqual true - - //encode size - PacketCoding.EncodePacket( - ReplicationStreamMessage(6, None, - Vector( - SquadListing(1, Some(SquadHeader(128, true, Some(2), Some(SquadInfo(None, None, None, None, None, None))))), - SquadListing(255) - ) - ) - ).isFailure mustEqual true - - //encode leader and size - PacketCoding.EncodePacket( - ReplicationStreamMessage(6, None, - Vector( - SquadListing(5, Some(SquadHeader(129, false, Some(0), Some(SquadInfo(None, None, None, None, None, None))))), - SquadListing(255) - ) - ) - ).isFailure mustEqual true - - //encode task and continent - PacketCoding.EncodePacket( - ReplicationStreamMessage(6, None, - Vector( - SquadListing(5, Some(SquadHeader(129, false, Some(1), Some(SquadInfo(None, None, None, None, None, None))))), - SquadListing(255) - ) - ) - ).isFailure mustEqual true - - //encode all - PacketCoding.EncodePacket( - ReplicationStreamMessage(6, None, - Vector( - SquadListing(7, Some(SquadHeader(131, false, None, Some(SquadInfo(None, None, None, None, None, None))))), - SquadListing(255) - ) - ) - ).isFailure mustEqual true + pkt mustEqual stringRemoveUpdate } } diff --git a/common/src/test/scala/game/SquadDefinitionActionMessageTest.scala b/common/src/test/scala/game/SquadDefinitionActionMessageTest.scala index a9657decb..3ef295770 100644 --- a/common/src/test/scala/game/SquadDefinitionActionMessageTest.scala +++ b/common/src/test/scala/game/SquadDefinitionActionMessageTest.scala @@ -3,12 +3,17 @@ package game import org.specs2.mutable._ import net.psforever.packet._ +import net.psforever.packet.game.SquadAction._ import net.psforever.packet.game._ +import net.psforever.types.CertificationType import scodec.bits._ class SquadDefinitionActionMessageTest extends Specification { //local test data; note that the second field - unk1 - is always blank for now, but that probably changes + val string_00 = hex"e7 00 0c0000" //guid: 3 val string_03 = hex"E7 0c 0000c0" //index: 3 + val string_04 = hex"E7 10 0000c0" //index: 3 + val string_07 = hex"e7 1c 0000e68043006f0070007300200061006e00640020004d0069006c006900740061007200790020004f006600660069006300650072007300" val string_08 = hex"E7 20 000000" val string_10 = hex"E7 28 000004" //index: 1 val string_19 = hex"E7 4c 0000218041002d005400650061006d00" //"A-Team" @@ -30,18 +35,48 @@ class SquadDefinitionActionMessageTest extends Specification { val string_40 = hex"E7 a0 000004" //index: 1 val string_41 = hex"E7 a4 000000" + val string_43 = hex"e7 ac 000000" + val string_failure = hex"E7 ff" + + "decode (00)" in { + PacketCoding.DecodePacket(string_00).require match { + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(3) + unk2 mustEqual 0 + action mustEqual DisplaySquad() + case _ => + ko + } + } + "decode (03)" in { PacketCoding.DecodePacket(string_03).require match { - case SquadDefinitionActionMessage(action, unk1, unk2, str, int1, int2, long1, long2, bool) => - action mustEqual 3 - unk1 mustEqual 0 + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(0) unk2 mustEqual 3 - str.isDefined mustEqual false - int1.isDefined mustEqual false - int2.isDefined mustEqual false - long1.isDefined mustEqual false - long2.isDefined mustEqual false - bool.isDefined mustEqual false + action mustEqual SaveSquadFavorite() + case _ => + ko + } + } + + "decode (03)" in { + PacketCoding.DecodePacket(string_04).require match { + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(0) + unk2 mustEqual 3 + action mustEqual LoadSquadFavorite() + case _ => + ko + } + } + + "decode (07)" in { + PacketCoding.DecodePacket(string_07).require match { + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(0) + unk2 mustEqual 3 + action mustEqual ListSquadFavorite("Cops and Military Officers") case _ => ko } @@ -49,16 +84,10 @@ class SquadDefinitionActionMessageTest extends Specification { "decode (08)" in { PacketCoding.DecodePacket(string_08).require match { - case SquadDefinitionActionMessage(action, unk1, unk2, str, int1, int2, long1, long2, bool) => - action mustEqual 8 - unk1 mustEqual 0 + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(0) unk2 mustEqual 0 - str.isDefined mustEqual false - int1.isDefined mustEqual false - int2.isDefined mustEqual false - long1.isDefined mustEqual false - long2.isDefined mustEqual false - bool.isDefined mustEqual false + action mustEqual RequestListSquad() case _ => ko } @@ -66,17 +95,10 @@ class SquadDefinitionActionMessageTest extends Specification { "decode (10)" in { PacketCoding.DecodePacket(string_10).require match { - case SquadDefinitionActionMessage(action, unk1, unk2, str, int1, int2, long1, long2, bool) => - action mustEqual 10 - unk1 mustEqual 0 + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(0) unk2 mustEqual 0 - str.isDefined mustEqual false - int1.isDefined mustEqual true - int1.get mustEqual 1 - int2.isDefined mustEqual false - long1.isDefined mustEqual false - long2.isDefined mustEqual false - bool.isDefined mustEqual false + action mustEqual SelectRoleForYourself(1) case _ => ko } @@ -84,17 +106,10 @@ class SquadDefinitionActionMessageTest extends Specification { "decode (19)" in { PacketCoding.DecodePacket(string_19).require match { - case SquadDefinitionActionMessage(action, unk1, unk2, str, int1, int2, long1, long2, bool) => - action mustEqual 19 - unk1 mustEqual 0 + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(0) unk2 mustEqual 0 - str.isDefined mustEqual true - str.get mustEqual "A-Team" - int1.isDefined mustEqual false - int2.isDefined mustEqual false - long1.isDefined mustEqual false - long2.isDefined mustEqual false - bool.isDefined mustEqual false + action mustEqual ChangeSquadPurpose("A-Team") case _ => ko } @@ -102,17 +117,10 @@ class SquadDefinitionActionMessageTest extends Specification { "decode (20)" in { PacketCoding.DecodePacket(string_20).require match { - case SquadDefinitionActionMessage(action, unk1, unk2, str, int1, int2, long1, long2, bool) => - action mustEqual 20 - unk1 mustEqual 0 + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(0) unk2 mustEqual 0 - str.isDefined mustEqual false - int1.isDefined mustEqual true - int1.get mustEqual 1 - int2.isDefined mustEqual false - long1.isDefined mustEqual false - long2.isDefined mustEqual false - bool.isDefined mustEqual false + action mustEqual ChangeSquadZone(PlanetSideZoneID(1)) case _ => ko } @@ -120,17 +128,10 @@ class SquadDefinitionActionMessageTest extends Specification { "decode (21)" in { PacketCoding.DecodePacket(string_21).require match { - case SquadDefinitionActionMessage(action, unk1, unk2, str, int1, int2, long1, long2, bool) => - action mustEqual 21 - unk1 mustEqual 0 + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(0) unk2 mustEqual 0 - str.isDefined mustEqual false - int1.isDefined mustEqual true - int1.get mustEqual 2 - int2.isDefined mustEqual false - long1.isDefined mustEqual false - long2.isDefined mustEqual false - bool.isDefined mustEqual false + action mustEqual CloseSquadMemberPosition(2) case _ => ko } @@ -138,17 +139,10 @@ class SquadDefinitionActionMessageTest extends Specification { "decode (22)" in { PacketCoding.DecodePacket(string_22).require match { - case SquadDefinitionActionMessage(action, unk1, unk2, str, int1, int2, long1, long2, bool) => - action mustEqual 22 - unk1 mustEqual 0 + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(0) unk2 mustEqual 0 - str.isDefined mustEqual false - int1.isDefined mustEqual true - int1.get mustEqual 2 - int2.isDefined mustEqual false - long1.isDefined mustEqual false - long2.isDefined mustEqual false - bool.isDefined mustEqual false + action mustEqual AddSquadMemberPosition(2) case _ => ko } @@ -156,18 +150,10 @@ class SquadDefinitionActionMessageTest extends Specification { "decode (23)" in { PacketCoding.DecodePacket(string_23).require match { - case SquadDefinitionActionMessage(action, unk1, unk2, str, int1, int2, long1, long2, bool) => - action mustEqual 23 - unk1 mustEqual 0 + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(0) unk2 mustEqual 0 - str.isDefined mustEqual true - str.get mustEqual "BLUFOR" - int1.isDefined mustEqual true - int1.get mustEqual 1 - int2.isDefined mustEqual false - long1.isDefined mustEqual false - long2.isDefined mustEqual false - bool.isDefined mustEqual false + action mustEqual ChangeSquadMemberRequirementsRole(1, "BLUFOR") case _ => ko } @@ -175,18 +161,10 @@ class SquadDefinitionActionMessageTest extends Specification { "decode (24)" in { PacketCoding.DecodePacket(string_24).require match { - case SquadDefinitionActionMessage(action, unk1, unk2, str, int1, int2, long1, long2, bool) => - action mustEqual 24 - unk1 mustEqual 0 + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(0) unk2 mustEqual 0 - str.isDefined mustEqual true - str.get mustEqual "kill bad dudes" - int1.isDefined mustEqual true - int1.get mustEqual 1 - int2.isDefined mustEqual false - long1.isDefined mustEqual false - long2.isDefined mustEqual false - bool.isDefined mustEqual false + action mustEqual ChangeSquadMemberRequirementsDetailedOrders(1, "kill bad dudes") case _ => ko } @@ -194,18 +172,13 @@ class SquadDefinitionActionMessageTest extends Specification { "decode (25)" in { PacketCoding.DecodePacket(string_25).require match { - case SquadDefinitionActionMessage(action, unk1, unk2, str, int1, int2, long1, long2, bool) => - action mustEqual 25 - unk1 mustEqual 0 + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(0) unk2 mustEqual 0 - str.isDefined mustEqual false - int1.isDefined mustEqual true - int1.get mustEqual 1 - int2.isDefined mustEqual false - long1.isDefined mustEqual true - long1.get mustEqual 536870928L - long2.isDefined mustEqual false - bool.isDefined mustEqual false + action mustEqual ChangeSquadMemberRequirementsCertifications( + 1, + Set(CertificationType.AntiVehicular, CertificationType.InfiltrationSuit) + ) case _ => ko } @@ -213,16 +186,10 @@ class SquadDefinitionActionMessageTest extends Specification { "decode (26)" in { PacketCoding.DecodePacket(string_26).require match { - case SquadDefinitionActionMessage(action, unk1, unk2, str, int1, int2, long1, long2, bool) => - action mustEqual 26 - unk1 mustEqual 0 + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(0) unk2 mustEqual 0 - str.isDefined mustEqual false - int1.isDefined mustEqual false - int2.isDefined mustEqual false - long1.isDefined mustEqual false - long2.isDefined mustEqual false - bool.isDefined mustEqual false + action mustEqual ResetAll() case _ => ko } @@ -230,17 +197,10 @@ class SquadDefinitionActionMessageTest extends Specification { "decode (28)" in { PacketCoding.DecodePacket(string_28).require match { - case SquadDefinitionActionMessage(action, unk1, unk2, str, int1, int2, long1, long2, bool) => - action mustEqual 28 - unk1 mustEqual 0 + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(0) unk2 mustEqual 0 - str.isDefined mustEqual false - int1.isDefined mustEqual false - int2.isDefined mustEqual false - long1.isDefined mustEqual false - long2.isDefined mustEqual false - bool.isDefined mustEqual true - bool.get mustEqual true + action mustEqual AutoApproveInvitationRequests(true) case _ => ko } @@ -248,17 +208,10 @@ class SquadDefinitionActionMessageTest extends Specification { "decode (31)" in { PacketCoding.DecodePacket(string_31).require match { - case SquadDefinitionActionMessage(action, unk1, unk2, str, int1, int2, long1, long2, bool) => - action mustEqual 31 - unk1 mustEqual 0 + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(0) unk2 mustEqual 0 - str.isDefined mustEqual false - int1.isDefined mustEqual false - int2.isDefined mustEqual false - long1.isDefined mustEqual false - long2.isDefined mustEqual false - bool.isDefined mustEqual true - bool.get mustEqual true + action mustEqual LocationFollowsSquadLead(true) case _ => ko } @@ -266,20 +219,10 @@ class SquadDefinitionActionMessageTest extends Specification { "decode (34a)" in { PacketCoding.DecodePacket(string_34a).require match { - case SquadDefinitionActionMessage(action, unk1, unk2, str, int1, int2, long1, long2, bool) => - action mustEqual 34 - unk1 mustEqual 0 + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(0) unk2 mustEqual 0 - str.isDefined mustEqual true - str.get mustEqual "Badass" - int1.isDefined mustEqual true - int1.get mustEqual 1 - int2.isDefined mustEqual true - int2.get mustEqual 0 - long1.isDefined mustEqual true - long1.get mustEqual 0 - long2.isDefined mustEqual false - bool.isDefined mustEqual false + action mustEqual SearchForSquadsWithParticularRole("Badass", Set(), 1, SearchMode.AnyPositions) case _ => ko } @@ -287,20 +230,10 @@ class SquadDefinitionActionMessageTest extends Specification { "decode (34b)" in { PacketCoding.DecodePacket(string_34b).require match { - case SquadDefinitionActionMessage(action, unk1, unk2, str, int1, int2, long1, long2, bool) => - action mustEqual 34 - unk1 mustEqual 0 + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(0) unk2 mustEqual 0 - str.isDefined mustEqual true - str.get mustEqual "Badass" - int1.isDefined mustEqual true - int1.get mustEqual 2 - int2.isDefined mustEqual true - int2.get mustEqual 0 - long1.isDefined mustEqual true - long1.get mustEqual 0 - long2.isDefined mustEqual false - bool.isDefined mustEqual false + action mustEqual SearchForSquadsWithParticularRole("Badass", Set(), 2, SearchMode.AnyPositions) case _ => ko } @@ -308,20 +241,10 @@ class SquadDefinitionActionMessageTest extends Specification { "decode (34c)" in { PacketCoding.DecodePacket(string_34c).require match { - case SquadDefinitionActionMessage(action, unk1, unk2, str, int1, int2, long1, long2, bool) => - action mustEqual 34 - unk1 mustEqual 0 + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(0) unk2 mustEqual 0 - str.isDefined mustEqual true - str.get mustEqual "Badass" - int1.isDefined mustEqual true - int1.get mustEqual 2 - int2.isDefined mustEqual true - int2.get mustEqual 1 - long1.isDefined mustEqual true - long1.get mustEqual 0 - long2.isDefined mustEqual false - bool.isDefined mustEqual false + action mustEqual SearchForSquadsWithParticularRole("Badass", Set(), 2, SearchMode.AvailablePositions) case _ => ko } @@ -329,20 +252,10 @@ class SquadDefinitionActionMessageTest extends Specification { "decode (34d)" in { PacketCoding.DecodePacket(string_34d).require match { - case SquadDefinitionActionMessage(action, unk1, unk2, str, int1, int2, long1, long2, bool) => - action mustEqual 34 - unk1 mustEqual 0 + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(0) unk2 mustEqual 0 - str.isDefined mustEqual true - str.get mustEqual "Badass" - int1.isDefined mustEqual true - int1.get mustEqual 2 - int2.isDefined mustEqual true - int2.get mustEqual 2 - long1.isDefined mustEqual true - long1.get mustEqual 536870928L - long2.isDefined mustEqual false - bool.isDefined mustEqual false + action mustEqual SearchForSquadsWithParticularRole("Badass", Set(CertificationType.InfiltrationSuit, CertificationType.AntiVehicular), 2, SearchMode.SomeCertifications) case _ => ko } @@ -350,20 +263,10 @@ class SquadDefinitionActionMessageTest extends Specification { "decode (34e)" in { PacketCoding.DecodePacket(string_34e).require match { - case SquadDefinitionActionMessage(action, unk1, unk2, str, int1, int2, long1, long2, bool) => - action mustEqual 34 - unk1 mustEqual 0 + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(0) unk2 mustEqual 0 - str.isDefined mustEqual true - str.get mustEqual "Badass" - int1.isDefined mustEqual true - int1.get mustEqual 2 - int2.isDefined mustEqual true - int2.get mustEqual 3 - long1.isDefined mustEqual true - long1.get mustEqual 536870928L - long2.isDefined mustEqual false - bool.isDefined mustEqual false + action mustEqual SearchForSquadsWithParticularRole("Badass", Set(CertificationType.InfiltrationSuit, CertificationType.AntiVehicular), 2, SearchMode.AllCertifications) case _ => ko } @@ -371,16 +274,10 @@ class SquadDefinitionActionMessageTest extends Specification { "decode (35)" in { PacketCoding.DecodePacket(string_35).require match { - case SquadDefinitionActionMessage(action, unk1, unk2, str, int1, int2, long1, long2, bool) => - action mustEqual 35 - unk1 mustEqual 0 + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(0) unk2 mustEqual 0 - str.isDefined mustEqual false - int1.isDefined mustEqual false - int2.isDefined mustEqual false - long1.isDefined mustEqual false - long2.isDefined mustEqual false - bool.isDefined mustEqual false + action mustEqual CancelSquadSearch() case _ => ko } @@ -388,17 +285,10 @@ class SquadDefinitionActionMessageTest extends Specification { "decode (40)" in { PacketCoding.DecodePacket(string_40).require match { - case SquadDefinitionActionMessage(action, unk1, unk2, str, int1, int2, long1, long2, bool) => - action mustEqual 40 - unk1 mustEqual 0 + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(0) unk2 mustEqual 0 - str.isDefined mustEqual false - int1.isDefined mustEqual true - int1.get mustEqual 1 - int2.isDefined mustEqual false - long1.isDefined mustEqual false - long2.isDefined mustEqual false - bool.isDefined mustEqual false + action mustEqual FindLfsSoldiersForRole(1) case _ => ko } @@ -406,165 +296,205 @@ class SquadDefinitionActionMessageTest extends Specification { "decode (41)" in { PacketCoding.DecodePacket(string_41).require match { - case SquadDefinitionActionMessage(action, unk1, unk2, str, int1, int2, long1, long2, bool) => - action mustEqual 41 - unk1 mustEqual 0 + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(0) unk2 mustEqual 0 - str.isDefined mustEqual false - int1.isDefined mustEqual false - int2.isDefined mustEqual false - long1.isDefined mustEqual false - long2.isDefined mustEqual false - bool.isDefined mustEqual false + action mustEqual CancelFind() case _ => ko } } + "decode (43, unknown)" in { + PacketCoding.DecodePacket(string_43).require match { + case SquadDefinitionActionMessage(unk1, unk2, action) => + unk1 mustEqual PlanetSideGUID(0) + unk2 mustEqual 0 + action mustEqual Unknown(43, hex"00".toBitVector.take(6)) + case _ => + ko + } + } + + "decode (failure)" in { + PacketCoding.DecodePacket(string_failure).isFailure mustEqual true + } + + "encode (00)" in { + val msg = SquadDefinitionActionMessage(PlanetSideGUID(3), 0, DisplaySquad()) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string_00 + } + "encode (03)" in { - val msg = SquadDefinitionActionMessage(3, 0, 3, None, None, None, None, None, None) + val msg = SquadDefinitionActionMessage(PlanetSideGUID(0), 3, SaveSquadFavorite()) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_03 } + "encode (03)" in { + val msg = SquadDefinitionActionMessage(PlanetSideGUID(0), 3, LoadSquadFavorite()) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string_04 + } + + "encode (07)" in { + val msg = SquadDefinitionActionMessage(PlanetSideGUID(0), 3, ListSquadFavorite("Cops and Military Officers")) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string_07 + } + "encode (08)" in { - val msg = SquadDefinitionActionMessage(8, 0, 0, None, None, None, None, None, None) + val msg = SquadDefinitionActionMessage(PlanetSideGUID(0), 0, RequestListSquad()) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_08 } "encode (10)" in { - val msg = SquadDefinitionActionMessage(10, 0, 0, None, Some(1), None, None, None, None) + val msg = SquadDefinitionActionMessage(PlanetSideGUID(0), 0, SelectRoleForYourself(1)) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_10 } "encode (19)" in { - val msg = SquadDefinitionActionMessage(19, 0, 0, Some("A-Team"), None, None, None, None, None) + val msg = SquadDefinitionActionMessage(PlanetSideGUID(0), 0, ChangeSquadPurpose("A-Team")) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_19 } "encode (20)" in { - val msg = SquadDefinitionActionMessage(20, 0, 0, None, Some(1), None, None, None, None) + val msg = SquadDefinitionActionMessage(PlanetSideGUID(0), 0, ChangeSquadZone(PlanetSideZoneID(1))) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_20 } "encode (21)" in { - val msg = SquadDefinitionActionMessage(21, 0, 0, None, Some(2), None, None, None, None) + val msg = SquadDefinitionActionMessage(PlanetSideGUID(0), 0, CloseSquadMemberPosition(2)) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_21 } "encode (22)" in { - val msg = SquadDefinitionActionMessage(22, 0, 0, None, Some(2), None, None, None, None) + val msg = SquadDefinitionActionMessage(PlanetSideGUID(0), 0, AddSquadMemberPosition(2)) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_22 } "encode (23)" in { - val msg = SquadDefinitionActionMessage(23, 0, 0, Some("BLUFOR"), Some(1), None, None, None, None) + val msg = SquadDefinitionActionMessage(PlanetSideGUID(0), 0, ChangeSquadMemberRequirementsRole(1, "BLUFOR")) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_23 } "encode (24)" in { - val msg = SquadDefinitionActionMessage(24, 0, 0, Some("kill bad dudes"), Some(1), None, None, None, None) + val msg = SquadDefinitionActionMessage(PlanetSideGUID(0), 0, ChangeSquadMemberRequirementsDetailedOrders(1, "kill bad dudes")) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_24 } "encode (25)" in { - val msg = SquadDefinitionActionMessage(25, 0, 0, None, Some(1), None, Some(536870928L), None, None) + val msg = SquadDefinitionActionMessage(PlanetSideGUID(0), 0, ChangeSquadMemberRequirementsCertifications( + 1, + Set(CertificationType.AntiVehicular, CertificationType.InfiltrationSuit) + )) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_25 } "encode (26)" in { - val msg = SquadDefinitionActionMessage(26, 0, 0, None, None, None, None, None, None) + val msg = SquadDefinitionActionMessage(PlanetSideGUID(0), 0, ResetAll()) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_26 } "encode (28)" in { - val msg = SquadDefinitionActionMessage(28, 0, 0, None, None, None, None, None, Some(true)) + val msg = SquadDefinitionActionMessage(PlanetSideGUID(0), 0, AutoApproveInvitationRequests(true)) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_28 } "encode (31)" in { - val msg = SquadDefinitionActionMessage(31, 0, 0, None, None, None, None, None, Some(true)) + val msg = SquadDefinitionActionMessage(PlanetSideGUID(0), 0, LocationFollowsSquadLead(true)) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_31 } "encode (34a)" in { - val msg = SquadDefinitionActionMessage(34, 0, 0, Some("Badass"), Some(1), Some(0), Some(0L), None, None) + val msg = SquadDefinitionActionMessage(PlanetSideGUID(0), 0, SearchForSquadsWithParticularRole("Badass", Set(), 1, SearchMode.AnyPositions)) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_34a } "encode (34b)" in { - val msg = SquadDefinitionActionMessage(34, 0, 0, Some("Badass"), Some(2), Some(0), Some(0L), None, None) + val msg = SquadDefinitionActionMessage(PlanetSideGUID(0), 0, SearchForSquadsWithParticularRole("Badass", Set(), 2, SearchMode.AnyPositions)) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_34b } "encode (34c)" in { - val msg = SquadDefinitionActionMessage(34, 0, 0, Some("Badass"), Some(2), Some(1), Some(0L), None, None) + val msg = SquadDefinitionActionMessage(PlanetSideGUID(0), 0, SearchForSquadsWithParticularRole("Badass", Set(), 2, SearchMode.AvailablePositions)) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_34c } "encode (34d)" in { - val msg = SquadDefinitionActionMessage(34, 0, 0, Some("Badass"), Some(2), Some(2), Some(536870928L), None, None) + val msg = SquadDefinitionActionMessage(PlanetSideGUID(0), 0, SearchForSquadsWithParticularRole("Badass", Set(CertificationType.InfiltrationSuit, CertificationType.AntiVehicular), 2, SearchMode.SomeCertifications)) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_34d } "encode (34e)" in { - val msg = SquadDefinitionActionMessage(34, 0, 0, Some("Badass"), Some(2), Some(3), Some(536870928L), None, None) + val msg = SquadDefinitionActionMessage(PlanetSideGUID(0), 0, SearchForSquadsWithParticularRole("Badass", Set(CertificationType.InfiltrationSuit, CertificationType.AntiVehicular), 2, SearchMode.AllCertifications)) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_34e } "encode (35)" in { - val msg = SquadDefinitionActionMessage(35, 0, 0, None, None, None, None, None, None) + val msg = SquadDefinitionActionMessage(PlanetSideGUID(0), 0, CancelSquadSearch()) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_35 } "encode (40)" in { - val msg = SquadDefinitionActionMessage(40, 0, 0, None, Some(1), None, None, None, None) + val msg = SquadDefinitionActionMessage(PlanetSideGUID(0), 0, FindLfsSoldiersForRole(1)) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_40 } "encode (41)" in { - val msg = SquadDefinitionActionMessage(41, 0, 0, None, None, None, None, None, None) + val msg = SquadDefinitionActionMessage(PlanetSideGUID(0), 0, CancelFind()) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_41 } + + "encode (43, unknown)" in { + val msg = SquadDefinitionActionMessage(PlanetSideGUID(0), 0, Unknown(43, BitVector.empty)) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string_43 + } } diff --git a/common/src/test/scala/game/SquadDetailDefinitionUpdateMessageTest.scala b/common/src/test/scala/game/SquadDetailDefinitionUpdateMessageTest.scala new file mode 100644 index 000000000..9e3fd2c17 --- /dev/null +++ b/common/src/test/scala/game/SquadDetailDefinitionUpdateMessageTest.scala @@ -0,0 +1,716 @@ +// Copyright (c) 2019 PSForever +package game + +import net.psforever.packet._ +import net.psforever.packet.game._ +import net.psforever.types.CertificationType +import org.specs2.mutable._ +import scodec.bits._ + +class SquadDetailDefinitionUpdateMessageTest extends Specification { + val string_unk1 = hex"e80300818800015c5189004603408c000000012000ff" + val string_leader_char_id = hex"e8050080904d56b808" + val string_unk3LeaderName = hex"e80300821104145011b9be840024284a00610061006b006f008c008118000000024000ff" + val string_task = hex"e8050080ac6041006c006c002000570065006c0063006f006d0065002000" + val string_zone = hex"e8030080b0a8000000" + val string_taskZone = hex"e80200812ce05c002300460046003000300030003000200054006800650020005c002300660066006600660066006600200042006c0061006400650073006040000000" + val string_unk7 = hex"e8030081ac8054006800650020004b0069006e00670027007300200053007100750061006400788c09808c4854006800650020004700750061007200640008808c5054006800650020004b006e00690067006800740007808c4054006800650020004500610072006c0006808c4054006800650020004c006f007200640005808c405400680065002000440075006b00650004808c4854006800650020004200610072006f006e0003808c6054006800650020005000720069006e00630065007300730002808c5054006800650020005000720069006e006300650001808c48540068006500200051007500650065006e0000808c4054006800650020004b0069006e006700ff" + val string_member_closed = hex"e8030080c602c043fe" + val string_member_role = hex"e8070080c60040462443006f006d006d0061006e00640065007200ff" + val string_member_roleRequirements = hex"e8010080c60340862841004400560020004800610063006b00650072005000000002003fc0" + val string_member_charIdName = hex"e8030080c602c08f2658480123004400750063006b006d006100730074006500720034003300ff" + val string_task_memberEtc = hex"e80100812ce05c002300460046003000300030003000200054006800650020005c002300660066006600660066006600200042006c0061006400650073008c09810c005000000000000220230007808c0006808c0005808c0004808c0003808c0002808c0001808c0000808c00ff" + val string_full = hex"e80300848180038021514601288a8400420048006f0066004400bf5c0023006600660064006300300030002a002a002a005c0023003900360034003000660066003d004b004f004b002b005300500043002b0046004c0059003d005c0023006600660064006300300030002a002a002a005c002300460046003400300034003000200041006c006c002000570065006c0063006f006d006500070000009814010650005c00230066006600300030003000300020007c007c007c007c007c007c007c007c007c007c007c007c007c007c007c007c007c007c007c007c007c007c007c008000000000800100000c00020c8c5c00230066006600640063003000300020002000200043008000000000800100000c00020c8c5c002300660066006400630030003000200020002000480080eab58a02854f0070006f006c0045000100000c00020c8d5c002300660066006400630030003000200020002000200049008072d47a028b42006f006200610046003300740074003900300037000100000c00020c8c5c0023006600660064006300300030002000200020004e008000000000800100000c00020c8c5c00230066006600640063003000300020002000200041008000000000800100000c00020ca05c00230066006600300030003000300020007c007c007c007c007c007c007c007c007c007c007c007c007c007c007c007c007c007c007c007c007c007c007c008000000000800100000c00020c8c5c0023003900360034003000660066002000200020004b008000000000800100000c00020c8c5c0023003900360034003000660066002000200020004f008042a28c028448006f00660044000100000c00020c8c5c0023003900360034003000660066002000200020004b008000000000800100000c0000" + val string_mixed = hex"e80300812cd85000530046006f007200650076006500720020005000610063006b0065007400200043006f006c006c0065006300740069006f006e00841400000181306400800000000080000000000000220c808000000000800000000000001e0c808000000000800000000000001a0c80800000000080000000000000160c80800000000080000000000000120c808000000000800000000000000e0c808000000000800000000000000a0c80800000000080000000000000060c80800000000080000000000000020c80800000000080000000000003fc" + + "SquadDetailDefinitionUpdateMessage" should { + "decode (unk1 + members)" in { + PacketCoding.DecodePacket(string_unk1).require match { + case SquadDetailDefinitionUpdateMessage(guid, detail) => + guid mustEqual PlanetSideGUID(3) + detail match { + case SquadDetail(Some(unk1), None, Some(char_id), None, None, None, None, None, Some(_)) => + unk1 mustEqual 0 + char_id mustEqual 1221560L + //members tests follow ... + case _ => + ko + } + case _ => + ko + } + } + + "decode (char id)" in { + PacketCoding.DecodePacket(string_leader_char_id).require match { + case SquadDetailDefinitionUpdateMessage(guid, detail) => + guid mustEqual PlanetSideGUID(5) + detail match { + case SquadDetail(None, None, Some(char_id), None, None, None, None, None, None) => + char_id mustEqual 30910985 + case _ => + ko + } + ok + case _ => + ko + } + } + + "decode (unk3 + leader name)" in { + PacketCoding.DecodePacket(string_unk3LeaderName).require match { + case SquadDetailDefinitionUpdateMessage(guid, detail) => + guid mustEqual PlanetSideGUID(3) + detail match { + case SquadDetail(None, None, Some(char_id), Some(unk3), Some(leader), None, None, None, Some(_)) => + char_id mustEqual 42631712L + unk3 mustEqual 556403L + leader mustEqual "Jaako" + //members tests follow ... + case _ => + ko + } + case _ => + ko + } + } + + "decode (task)" in { + PacketCoding.DecodePacket(string_task).require match { + case SquadDetailDefinitionUpdateMessage(guid, detail) => + guid mustEqual PlanetSideGUID(5) + detail match { + case SquadDetail(None, None, None, None, None, Some(task), None, None, None) => + task mustEqual "All Welcome " + case _ => + ko + } + ok + case _ => + ko + } + } + + "decode (zone)" in { + PacketCoding.DecodePacket(string_zone).require match { + case SquadDetailDefinitionUpdateMessage(guid, detail) => + guid mustEqual PlanetSideGUID(3) + detail match { + case SquadDetail(None, None, None, None, None, None, Some(zone), None, None) => + zone mustEqual PlanetSideZoneID(21) + case _ => + ko + } + ok + case _ => + ko + } + } + + "decode (task + zone)" in { + PacketCoding.DecodePacket(string_taskZone).require match { + case SquadDetailDefinitionUpdateMessage(_, detail) => + detail match { + case SquadDetail(None, None, None, None, None, Some(task), Some(zone), None, None) => + task mustEqual "\\#FF0000 The \\#ffffff Blades" + zone mustEqual PlanetSideZoneID(4) + case _ => + ko + } + case _ => + ko + } + ok + } + + "decode (unk7 + members)" in { + PacketCoding.DecodePacket(string_unk7).require match { + case SquadDetailDefinitionUpdateMessage(guid, detail) => + guid mustEqual PlanetSideGUID(3) + detail match { + case SquadDetail(None, None, None, None, None, Some(task), None, Some(unk7), Some(_)) => + task mustEqual "The King's Squad" + unk7 mustEqual 8 + //members tests follow ... + case _ => + ko + } + case _ => + ko + } + } + + "decode (member closed)" in { + PacketCoding.DecodePacket(string_member_closed).require match { + case SquadDetailDefinitionUpdateMessage(guid, detail) => + guid mustEqual PlanetSideGUID(3) + detail match { + case SquadDetail(None, None, None, None, None, None, None, None, Some(members)) => + members.size mustEqual 1 + members.head.index mustEqual 5 + members.head.info match { + case Some(SquadPositionDetail(Some(is_closed), None, None, None, None, None)) => + is_closed mustEqual true + case _ => + ko + } + case _ => + ko + } + ok + case _ => + ko + } + } + + "decode (member role)" in { + PacketCoding.DecodePacket(string_member_role).require match { + case SquadDetailDefinitionUpdateMessage(guid, detail) => + guid mustEqual PlanetSideGUID(7) + detail match { + case SquadDetail(None, None, None, None, None, None, None, None, Some(members)) => + members.size mustEqual 1 + members.head.index mustEqual 0 + members.head.info match { + case Some(SquadPositionDetail(None, Some(role), None, None, None, None)) => + role mustEqual "Commander" + case _ => + ko + } + case _ => + ko + } + ok + case _ => + ko + } + } + + "decode (member role + requirements)" in { + PacketCoding.DecodePacket(string_member_roleRequirements).require match { + case SquadDetailDefinitionUpdateMessage(guid, detail) => + guid mustEqual PlanetSideGUID(1) + detail match { + case SquadDetail(None, None, None, None, None, None, None, None, Some(members)) => + members.size mustEqual 1 + members.head.index mustEqual 6 + members.head.info match { + case Some(SquadPositionDetail(None, Some(role), None, Some(req), None, None)) => + role mustEqual "ADV Hacker" + req.size mustEqual 1 + req.contains(CertificationType.AdvancedHacking) mustEqual true + case _ => + ko + } + case _ => + ko + } + ok + case _ => + ko + } + } + + "decode (member char id + name)" in { + PacketCoding.DecodePacket(string_member_charIdName).require match { + case SquadDetailDefinitionUpdateMessage(guid, detail) => + guid mustEqual PlanetSideGUID(3) + detail match { + case SquadDetail(None, None, None, None, None, None, None, None, Some(members)) => + members.size mustEqual 1 + members.head.index mustEqual 5 + members.head.info match { + case Some(SquadPositionDetail(None, None, None, None, Some(char_id), Some(name))) => + char_id mustEqual 1218249L + name mustEqual "Duckmaster43" + case _ => + ko + } + case _ => + ko + } + ok + case _ => + ko + } + } + + "decode (task + member etc)" in { + PacketCoding.DecodePacket(string_task_memberEtc).require match { + case SquadDetailDefinitionUpdateMessage(guid, detail) => + guid mustEqual PlanetSideGUID(1) + detail match { + case SquadDetail(None, None, None, None, None, Some(task), None, None, Some(members)) => + task mustEqual "\\#FF0000 The \\#ffffff Blades" + members.size mustEqual 10 + // + members.head.index mustEqual 9 + members.head.info match { + case Some(SquadPositionDetail(None, Some(role), None, Some(req), None, None)) => + role mustEqual "" + req mustEqual Set.empty + case _ => + ko + } + // + (1 to 9).foreach { index => + members(index).index mustEqual 9 - index + members(index).info match { + case Some(SquadPositionDetail(None, Some(role), None, None, None, None)) => + role mustEqual "" + case _ => + ko + } + } + case _ => + ko + } + case _ => + ko + } + ok + } + + "decode (full squad)" in { + PacketCoding.DecodePacket(string_full).require match { + case SquadDetailDefinitionUpdateMessage(guid, detail) => + guid mustEqual PlanetSideGUID(3) + detail match { + case SquadDetail(Some(u1), Some(u2), Some(char_id), Some(u3), Some(leader), Some(task), Some(zone), Some(unk7), Some(member_list)) => + u1 mustEqual 3 + u2 mustEqual 1792 + char_id mustEqual 42771010L + u3 mustEqual 529745L + leader mustEqual "HofD" + task mustEqual "\\#ffdc00***\\#9640ff=KOK+SPC+FLY=\\#ffdc00***\\#FF4040 All Welcome" + zone mustEqual PlanetSideZoneID(7) + unk7 mustEqual 4983296 + member_list.size mustEqual 10 + member_list.head mustEqual SquadPositionEntry(0,Some( + SquadPositionDetail( + Some(false), + Some("\\#ff0000 |||||||||||||||||||||||"), + Some(""), + Some(Set(CertificationType.StandardAssault, CertificationType.StandardExoSuit, CertificationType.AgileExoSuit)), + Some(0), + Some(""))) + ) + member_list(1) mustEqual SquadPositionEntry(1,Some( + SquadPositionDetail( + Some(false), + Some("\\#ffdc00 C"), + Some(""), + Some(Set(CertificationType.StandardAssault, CertificationType.StandardExoSuit, CertificationType.AgileExoSuit)), + Some(0), + Some(""))) + ) + member_list(2) mustEqual SquadPositionEntry(2,Some( + SquadPositionDetail( + Some(false), + Some("\\#ffdc00 H"), + Some(""), + Some(Set(CertificationType.StandardAssault, CertificationType.StandardExoSuit, CertificationType.AgileExoSuit)), + Some(42644970L), + Some("OpolE") + ) + )) + member_list(3) mustEqual SquadPositionEntry(3,Some( + SquadPositionDetail( + Some(false), + Some("\\#ffdc00 I"), + Some(""), + Some(Set(CertificationType.StandardAssault, CertificationType.StandardExoSuit, CertificationType.AgileExoSuit)), + Some(41604210L), + Some("BobaF3tt907") + ) + )) + member_list(4) mustEqual SquadPositionEntry(4,Some( + SquadPositionDetail( + Some(false), + Some("\\#ffdc00 N"), + Some(""), + Some(Set(CertificationType.StandardAssault, CertificationType.StandardExoSuit, CertificationType.AgileExoSuit)), + Some(0), + Some("") + ) + )) + member_list(5) mustEqual SquadPositionEntry(5,Some( + SquadPositionDetail( + Some(false), + Some("\\#ffdc00 A"), + Some(""), + Some(Set(CertificationType.StandardAssault, CertificationType.StandardExoSuit, CertificationType.AgileExoSuit)), + Some(0), + Some("") + ) + )) + member_list(6) mustEqual SquadPositionEntry(6,Some( + SquadPositionDetail( + Some(false), + Some("\\#ff0000 |||||||||||||||||||||||"), + Some(""), + Some(Set(CertificationType.StandardAssault, CertificationType.StandardExoSuit, CertificationType.AgileExoSuit)), + Some(0), + Some("") + ) + )) + member_list(7) mustEqual SquadPositionEntry(7,Some( + SquadPositionDetail( + Some(false), + Some("\\#9640ff K"), + Some(""), + Some(Set(CertificationType.StandardAssault, CertificationType.StandardExoSuit, CertificationType.AgileExoSuit)), + Some(0), + Some("") + ) + )) + member_list(8) mustEqual SquadPositionEntry(8,Some( + SquadPositionDetail( + Some(false), + Some("\\#9640ff O"), + Some(""), + Some(Set(CertificationType.StandardAssault, CertificationType.StandardExoSuit, CertificationType.AgileExoSuit)), + Some(42771010L), + Some("HofD") + ) + )) + member_list(9) mustEqual SquadPositionEntry(9,Some( + SquadPositionDetail( + Some(false), + Some("\\#9640ff K"), + Some(""), + Some(Set(CertificationType.StandardAssault, CertificationType.StandardExoSuit, CertificationType.AgileExoSuit)), + Some(0), + Some("") + ) + )) + case _ => + ko + } + case _ => + ko + } + } + + "decode (mixed)" in { + PacketCoding.DecodePacket(string_mixed).require match { + case SquadDetailDefinitionUpdateMessage(guid, detail) => + guid mustEqual PlanetSideGUID(3) + detail match { + case SquadDetail(None, None, None, None, None, Some(task), None, None, Some(member_list)) => + task mustEqual "PSForever Packet Collection" + member_list.size mustEqual 10 + member_list.head mustEqual SquadPositionEntry(9,Some( + SquadPositionDetail( + Some(false), + Some(""), + Some(""), + Some(Set.empty), + Some(0), + Some("") + )) + ) + member_list(1) mustEqual SquadPositionEntry(8,Some( + SquadPositionDetail( + Some(false), + Some(""), + Some(""), + Some(Set.empty), + Some(0), + Some("") + )) + ) + member_list(2) mustEqual SquadPositionEntry(7,Some( + SquadPositionDetail( + Some(false), + Some(""), + Some(""), + Some(Set.empty), + Some(0), + Some("") + ) + )) + member_list(3) mustEqual SquadPositionEntry(6,Some( + SquadPositionDetail( + Some(false), + Some(""), + Some(""), + Some(Set.empty), + Some(0), + Some("") + ) + )) + member_list(4) mustEqual SquadPositionEntry(5,Some( + SquadPositionDetail( + Some(false), + Some(""), + Some(""), + Some(Set.empty), + Some(0), + Some("") + ) + )) + member_list(5) mustEqual SquadPositionEntry(4,Some( + SquadPositionDetail( + Some(false), + Some(""), + Some(""), + Some(Set.empty), + Some(0), + Some("") + ) + )) + member_list(6) mustEqual SquadPositionEntry(3,Some( + SquadPositionDetail( + Some(false), + Some(""), + Some(""), + Some(Set.empty), + Some(0), + Some("") + ) + )) + member_list(7) mustEqual SquadPositionEntry(2,Some( + SquadPositionDetail( + Some(false), + Some(""), + Some(""), + Some(Set.empty), + Some(0), + Some("") + ) + )) + member_list(8) mustEqual SquadPositionEntry(1,Some( + SquadPositionDetail( + Some(false), + Some(""), + Some(""), + Some(Set.empty), + Some(0), + Some("") + ) + )) + member_list(9) mustEqual SquadPositionEntry(0,Some( + SquadPositionDetail( + Some(false), + Some(""), + Some(""), + Some(Set.empty), + Some(0), + Some("") + ) + )) + case _ => + ko + } + case _ => + ko + } + ok + } + + "encode (unk1 + members)" in { + val msg = SquadDetailDefinitionUpdateMessage( + PlanetSideGUID(3), + SquadDetail() + .Field1(0) + .LeaderCharId(1221560L) + .Members(List( + SquadPositionEntry(6, SquadPositionDetail().Player(0L, "")) + )) + ) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual string_unk1 + } + + "encode (char id)" in { + val msg = SquadDetailDefinitionUpdateMessage( + PlanetSideGUID(5), + SquadDetail().LeaderCharId(30910985L) + ) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual string_leader_char_id + } + + "encode (unk3 + leader name)" in { + val msg = SquadDetailDefinitionUpdateMessage( + PlanetSideGUID(3), + SquadDetail() + .Leader(42631712L, "Jaako") + .Field3(556403L) + .Members(List( + SquadPositionEntry(0, SquadPositionDetail().Player(0L, "")) + )) + ) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual string_unk3LeaderName + } + + "encode (task)" in { + val msg = SquadDetailDefinitionUpdateMessage( + PlanetSideGUID(5), + SquadDetail().Task("All Welcome ") + ) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual string_task + } + + "encode (zone)" in { + val msg = SquadDetailDefinitionUpdateMessage( + PlanetSideGUID(3), + SquadDetail().ZoneId(PlanetSideZoneID(21)) + ) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual string_zone + } + + "encode (task + zone)" in { + val msg = SquadDetailDefinitionUpdateMessage( + PlanetSideGUID(2), + SquadDetail() + .Task("\\#FF0000 The \\#ffffff Blades") + .ZoneId(PlanetSideZoneID(4)) + ) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual string_taskZone + } + + "encode (unk7 + members)" in { + val msg = SquadDetailDefinitionUpdateMessage( + PlanetSideGUID(3), + SquadDetail() + .Task("The King's Squad") + .Field7(8) + .Members(List( + SquadPositionEntry(9, SquadPositionDetail().Role("The Guard")), + SquadPositionEntry(8, SquadPositionDetail().Role("The Knight")), + SquadPositionEntry(7, SquadPositionDetail().Role("The Earl")), + SquadPositionEntry(6, SquadPositionDetail().Role("The Lord")), + SquadPositionEntry(5, SquadPositionDetail().Role("The Duke")), + SquadPositionEntry(4, SquadPositionDetail().Role("The Baron")), + SquadPositionEntry(3, SquadPositionDetail().Role("The Princess")), + SquadPositionEntry(2, SquadPositionDetail().Role("The Prince")), + SquadPositionEntry(1, SquadPositionDetail().Role("The Queen")), + SquadPositionEntry(0, SquadPositionDetail().Role("The King")) + )) + ) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual string_unk7 + } + + "encode (member closed)" in { + val msg = SquadDetailDefinitionUpdateMessage( + PlanetSideGUID(3), + SquadDetail() + .Members(List( + SquadPositionEntry(5, SquadPositionDetail.Closed) + )) + ) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual string_member_closed + } + + + "encode (member role)" in { + val msg = SquadDetailDefinitionUpdateMessage( + PlanetSideGUID(7), + SquadDetail() + .Members(List( + SquadPositionEntry(0, SquadPositionDetail().Role("Commander")) + )) + ) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual string_member_role + } + + "encode (member role + requirements)" in { + val msg = SquadDetailDefinitionUpdateMessage( + PlanetSideGUID(1), + SquadDetail() + .Members(List( + SquadPositionEntry(6, SquadPositionDetail() + .Role("ADV Hacker") + .Requirements(Set(CertificationType.AdvancedHacking))) + )) + ) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual string_member_roleRequirements + } + + "encode (member char id + name)" in { + val msg = SquadDetailDefinitionUpdateMessage( + PlanetSideGUID(3), + SquadDetail() + .Members(List( + SquadPositionEntry(5, SquadPositionDetail().Player(1218249L, "Duckmaster43")) + )) + ) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual string_member_charIdName + } + + "encode (task + member etc)" in { + val msg = SquadDetailDefinitionUpdateMessage( + PlanetSideGUID(1), + SquadDetail() + .Task("\\#FF0000 The \\#ffffff Blades") + .Members(List( + SquadPositionEntry(9, SquadPositionDetail().Role("").Requirements(Set())), + SquadPositionEntry(8, SquadPositionDetail().Role("")), + SquadPositionEntry(7, SquadPositionDetail().Role("")), + SquadPositionEntry(6, SquadPositionDetail().Role("")), + SquadPositionEntry(5, SquadPositionDetail().Role("")), + SquadPositionEntry(4, SquadPositionDetail().Role("")), + SquadPositionEntry(3, SquadPositionDetail().Role("")), + SquadPositionEntry(2, SquadPositionDetail().Role("")), + SquadPositionEntry(1, SquadPositionDetail().Role("")), + SquadPositionEntry(0, SquadPositionDetail().Role("")) + )) + ) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual string_task_memberEtc + } + + "encode (full squad)" in { + val msg = SquadDetailDefinitionUpdateMessage( + PlanetSideGUID(3), + SquadDetail( + 3, + 1792, + 42771010L, + 529745L, + "HofD", + "\\#ffdc00***\\#9640ff=KOK+SPC+FLY=\\#ffdc00***\\#FF4040 All Welcome", + PlanetSideZoneID(7), + 4983296, + List( + SquadPositionEntry(0, SquadPositionDetail("\\#ff0000 |||||||||||||||||||||||", "", Set(), 0, "")), + SquadPositionEntry(1, SquadPositionDetail("\\#ffdc00 C", "", Set(), 0, "")), + SquadPositionEntry(2, SquadPositionDetail("\\#ffdc00 H", "", Set(), 42644970L, "OpolE")), + SquadPositionEntry(3, SquadPositionDetail("\\#ffdc00 I", "", Set(), 41604210L, "BobaF3tt907")), + SquadPositionEntry(4, SquadPositionDetail("\\#ffdc00 N", "", Set(), 0, "")), + SquadPositionEntry(5, SquadPositionDetail("\\#ffdc00 A", "", Set(), 0, "")), + SquadPositionEntry(6, SquadPositionDetail("\\#ff0000 |||||||||||||||||||||||", "", Set(), 0, "")), + SquadPositionEntry(7, SquadPositionDetail("\\#9640ff K", "", Set(), 0, "")), + SquadPositionEntry(8, SquadPositionDetail("\\#9640ff O", "", Set(), 42771010L ,"HofD")), + SquadPositionEntry(9, SquadPositionDetail("\\#9640ff K", "", Set(), 0, "")) + ) + ) + ) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual string_full + } + + "encode (mixed)" in { + val position = Some(SquadPositionDetail("", "", Set(), 0, "")) + val msg = SquadDetailDefinitionUpdateMessage( + PlanetSideGUID(3), + SquadDetail + .Task("PSForever Packet Collection") + .Members((0 to 9).map { index => SquadPositionEntry(index, position) }.reverse.toList) + ) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual string_mixed + } + } +} diff --git a/common/src/test/scala/game/SquadMemberEventTest.scala b/common/src/test/scala/game/SquadMemberEventTest.scala new file mode 100644 index 000000000..2f431991f --- /dev/null +++ b/common/src/test/scala/game/SquadMemberEventTest.scala @@ -0,0 +1,32 @@ +// Copyright (c) 2019 PSForever +package game + +import net.psforever.packet._ +import net.psforever.packet.game._ +import org.specs2.mutable._ +import scodec.bits._ + +class SquadMemberEventTest extends Specification { + val string = hex"7000e008545180410848006f0066004400070051150800" + + "decode" in { + PacketCoding.DecodePacket(string).require match { + case SquadMemberEvent(u1, u2, u3, u4, u5, u6, u7) => + u1 mustEqual MemberEvent.Add + u2 mustEqual 7 + u3 mustEqual 42771010L + u4 mustEqual 0 + u5.contains("HofD") mustEqual true + u6.contains(7) mustEqual true + u7.contains(529745L) mustEqual true + case _ => + ko + } + } + + "encode" in { + val msg = SquadMemberEvent(MemberEvent.Add, 7, 42771010L, 0, Some("HofD"), Some(7), Some(529745L)) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual string + } +} diff --git a/common/src/test/scala/game/SquadMembershipRequestTest.scala b/common/src/test/scala/game/SquadMembershipRequestTest.scala new file mode 100644 index 000000000..79cb9319f --- /dev/null +++ b/common/src/test/scala/game/SquadMembershipRequestTest.scala @@ -0,0 +1,67 @@ +// Copyright (c) 2019 PSForever +package game + +import net.psforever.packet._ +import net.psforever.packet.game._ +import net.psforever.types.SquadRequestType +import org.specs2.mutable._ +import scodec.bits._ + +class SquadMembershipRequestTest extends Specification { + //481c897e-b47b-41cc-b7ad-c604606d985e / PSCap-2016-03-18_12-48-12-PM.gcap / Game record 5521 at 662.786844s + val string1 = hex"6e015aa7a0224d87a0280000" + //... / PSCap-2016-06-29_07-49-26-PM (last).gcap / Game record 77 at 9.732430 (found in MultiPacket) + val string2 = hex"6E265DD7A02800" + //TODO find example where player_name field is defined + //TODO find example where unk field is defined and is a string + + "decode (1)" in { + PacketCoding.DecodePacket(string1).require match { + case SquadMembershipRequest(req_type, unk2, unk3, p_name, unk5) => + req_type mustEqual SquadRequestType.Invite + unk2 mustEqual 41593365L + unk3.contains(41605156L) mustEqual true + p_name mustEqual "" + unk5.contains(None) mustEqual true + case _ => + ko + } + } + + "decode (2)" in { + PacketCoding.DecodePacket(string2).require match { + case SquadMembershipRequest(req_type, unk2, unk3, p_name, unk5) => + req_type mustEqual SquadRequestType.Accept + unk2 mustEqual 41606501L + unk3.isEmpty mustEqual true + p_name mustEqual "" + unk5.isEmpty mustEqual true + case _ => + ko + } + } + + "encode (1)" in { + val msg = SquadMembershipRequest(SquadRequestType.Invite, 41593365, Some(41605156L), "", Some(None)) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual string1 + } + + "encode (1; failure 1)" in { + SquadMembershipRequest(SquadRequestType.Invite, 41593365, None, "", Some(None)) must throwA[AssertionError] + } + + "encode (1; failure 2)" in { + SquadMembershipRequest(SquadRequestType.Invite, 41593365, Some(41605156L), "", None) must throwA[AssertionError] + } + + "encode (2)" in { + val msg = SquadMembershipRequest(SquadRequestType.Accept, 41606501, None, "", None) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual string2 + } + + "encode (2; failure)" in { + SquadMembershipRequest(SquadRequestType.Accept, 41606501, Some(41606501), "", None) must throwA[AssertionError] + } +} diff --git a/common/src/test/scala/game/SquadMembershipResponseTest.scala b/common/src/test/scala/game/SquadMembershipResponseTest.scala new file mode 100644 index 000000000..f9ff10c2c --- /dev/null +++ b/common/src/test/scala/game/SquadMembershipResponseTest.scala @@ -0,0 +1,421 @@ +// Copyright (c) 2019 PSForever +package game + +import net.psforever.packet._ +import net.psforever.packet.game._ +import net.psforever.types.SquadResponseType +import org.specs2.mutable._ +import scodec.bits._ + +class SquadMembershipResponseTest extends Specification { + val string_01 = hex"6f0 00854518050db2260108048006f006600440000" + val string_02 = hex"6f0 0049e8220112aa1e01100530050004f0049004c0045005200530080" + val string_11 = hex"6f1 995364f2040000000100080" + val string_12 = hex"6f1 90cadcf4040000000100080" + val string_21 = hex"6f2 010db2260085451805140560069007200750073004700690076006500720080" + val string_22 = hex"6f2 010db22601da03aa03140560069007200750073004700690076006500720080" + val string_31 = hex"6f3 07631db202854518050a048004d0046004900430000" + val string_32 = hex"6f3 04c34fb402854518050e0440041004e00310031003100310000" + val string_41 = hex"6f4 04cadcf405bbbef405140530041007200610069007300560061006e00750000" + val string_42 = hex"6f4 05c9c0f405d71aec0516041006900720049006e006a006500630074006f00720000" + val string_51 = hex"6f5 0249e8220049e822010e0430043005200490044004500520080" + val string_71 = hex"6f7 1049e822000000000100080" + val string_72 = hex"6f7 00cadcf4041355ae03100570069007a006b00690064003400350080" + val string_81 = hex"6f8 001355ae02cadcf405100570069007a006b00690064003400350000" + val string_91 = hex"6f9 008310080115aef40500080" + val string_92 = hex"6f9 001355ae02cadcf405100570069007a006b00690064003400350000" + val string_b1 = hex"6fb 021355ae02cadcf405140530041007200610069007300560061006e00750000" + + "SquadMembershipResponse" should { + "decode (0-1)" in { + PacketCoding.DecodePacket(string_01).require match { + case SquadMembershipResponse(unk1, unk2, unk3, unk4, unk5, unk6, unk7, unk8) => + unk1 mustEqual SquadResponseType.Invite + unk2 mustEqual 0 + unk3 mustEqual 0 + unk4 mustEqual 42771010L + unk5.contains(1300870L) mustEqual true + unk6 mustEqual "HofD" + unk7 mustEqual false + unk8.isEmpty mustEqual true + case _ => + ko + } + } + + "decode (0-2)" in { + PacketCoding.DecodePacket(string_02).require match { + case SquadMembershipResponse(unk1, unk2, unk3, unk4, unk5, unk6, unk7, unk8) => + unk1 mustEqual SquadResponseType.Invite + unk2 mustEqual 0 + unk3 mustEqual 0 + unk4 mustEqual 1176612L + unk5.contains(1004937L) mustEqual true + unk6 mustEqual "SPOILERS" + unk7 mustEqual true + unk8.isEmpty mustEqual true + case _ => + ko + } + } + + "decode (1-1)" in { + PacketCoding.DecodePacket(string_11).require match { + case SquadMembershipResponse(unk1, unk2, unk3, unk4, unk5, unk6, unk7, unk8) => + unk1 mustEqual SquadResponseType.Unk01 + unk2 mustEqual 19 + unk3 mustEqual 0 + unk4 mustEqual 41530025L + unk5.contains(0L) mustEqual true + unk6 mustEqual "" + unk7 mustEqual true + unk8.contains(None) mustEqual true + case _ => + ko + } + } + + "decode (1-2)" in { + PacketCoding.DecodePacket(string_12).require match { + case SquadMembershipResponse(unk1, unk2, unk3, unk4, unk5, unk6, unk7, unk8) => + unk1 mustEqual SquadResponseType.Unk01 + unk2 mustEqual 18 + unk3 mustEqual 0 + unk4 mustEqual 41578085L + unk5.contains(0L) mustEqual true + unk6 mustEqual "" + unk7 mustEqual true + unk8.contains(None) mustEqual true + case _ => + ko + } + } + + "decode (2-1)" in { + PacketCoding.DecodePacket(string_21).require match { + case SquadMembershipResponse(unk1, unk2, unk3, unk4, unk5, unk6, unk7, unk8) => + unk1 mustEqual SquadResponseType.Accept + unk2 mustEqual 0 + unk3 mustEqual 0 + unk4 mustEqual 1300870L + unk5.contains(42771010L) mustEqual true + unk6 mustEqual "VirusGiver" + unk7 mustEqual true + unk8.contains(None) mustEqual true + case _ => + ko + } + } + + "decode (2-2)" in { + PacketCoding.DecodePacket(string_22).require match { + case SquadMembershipResponse(unk1, unk2, unk3, unk4, unk5, unk6, unk7, unk8) => + unk1 mustEqual SquadResponseType.Accept + unk2 mustEqual 0 + unk3 mustEqual 0 + unk4 mustEqual 1300870L + unk5.contains(30736877L) mustEqual true + unk6 mustEqual "VirusGiver" + unk7 mustEqual true + unk8.contains(None) mustEqual true + case _ => + ko + } + } + + "decode (3-1)" in { + PacketCoding.DecodePacket(string_31).require match { + case SquadMembershipResponse(unk1, unk2, unk3, unk4, unk5, unk6, unk7, unk8) => + unk1 mustEqual SquadResponseType.Reject + unk2 mustEqual 0 + unk3 mustEqual 3 + unk4 mustEqual 31035057L + unk5.contains(42771010L) mustEqual true + unk6 mustEqual "HMFIC" + unk7 mustEqual false + unk8.contains(None) mustEqual true + case _ => + ko + } + } + + "decode (3-2)" in { + PacketCoding.DecodePacket(string_32).require match { + case SquadMembershipResponse(unk1, unk2, unk3, unk4, unk5, unk6, unk7, unk8) => + unk1 mustEqual SquadResponseType.Reject + unk2 mustEqual 0 + unk3 mustEqual 2 + unk4 mustEqual 31106913L + unk5.contains(42771010L) mustEqual true + unk6 mustEqual "DAN1111" + unk7 mustEqual false + unk8.contains(None) mustEqual true + case _ => + ko + } + } + + "decode (4-1)" in { + PacketCoding.DecodePacket(string_41).require match { + case SquadMembershipResponse(unk1, unk2, unk3, unk4, unk5, unk6, unk7, unk8) => + unk1 mustEqual SquadResponseType.Cancel + unk2 mustEqual 0 + unk3 mustEqual 2 + unk4 mustEqual 41578085L + unk5.contains(41607133L) mustEqual true + unk6 mustEqual "SAraisVanu" + unk7 mustEqual false + unk8.contains(None) mustEqual true + case _ => + ko + } + } + + "decode (4-2)" in { + PacketCoding.DecodePacket(string_42).require match { + case SquadMembershipResponse(unk1, unk2, unk3, unk4, unk5, unk6, unk7, unk8) => + unk1 mustEqual SquadResponseType.Cancel + unk2 mustEqual 0 + unk3 mustEqual 2 + unk4 mustEqual 41607396L + unk5.contains(41324011L) mustEqual true + unk6 mustEqual "AirInjector" + unk7 mustEqual false + unk8.contains(None) mustEqual true + case _ => + ko + } + } + + "decode (5-1)" in { + PacketCoding.DecodePacket(string_51).require match { + case SquadMembershipResponse(unk1, unk2, unk3, unk4, unk5, unk6, unk7, unk8) => + unk1 mustEqual SquadResponseType.Leave + unk2 mustEqual 0 + unk3 mustEqual 1 + unk4 mustEqual 1176612L + unk5.contains(1176612L) mustEqual true + unk6 mustEqual "CCRIDER" + unk7 mustEqual true + unk8.contains(None) mustEqual true + case _ => + ko + } + } + + "decode (7-1)" in { + PacketCoding.DecodePacket(string_71).require match { + case SquadMembershipResponse(unk1, unk2, unk3, unk4, unk5, unk6, unk7, unk8) => + unk1 mustEqual SquadResponseType.PlatoonInvite + unk2 mustEqual 2 + unk3 mustEqual 0 + unk4 mustEqual 1176612L + unk5.contains(0L) mustEqual true + unk6 mustEqual "" + unk7 mustEqual true + unk8.contains(None) mustEqual true + case _ => + ko + } + } + + "decode (7-2)" in { + PacketCoding.DecodePacket(string_72).require match { + case SquadMembershipResponse(unk1, unk2, unk3, unk4, unk5, unk6, unk7, unk8) => + unk1 mustEqual SquadResponseType.PlatoonInvite + unk2 mustEqual 0 + unk3 mustEqual 0 + unk4 mustEqual 41578085L + unk5.contains(30910985L) mustEqual true + unk6 mustEqual "Wizkid45" + unk7 mustEqual true + unk8.contains(None) mustEqual true + case _ => + ko + } + } + + "decode (8-1)" in { + PacketCoding.DecodePacket(string_81).require match { + case SquadMembershipResponse(unk1, unk2, unk3, unk4, unk5, unk6, unk7, unk8) => + unk1 mustEqual SquadResponseType.PlatoonAccept + unk2 mustEqual 0 + unk3 mustEqual 0 + unk4 mustEqual 30910985L + unk5.contains(41578085L) mustEqual true + unk6 mustEqual "Wizkid45" + unk7 mustEqual false + unk8.contains(None) mustEqual true + case _ => + ko + } + } + + "decode (9-1)" in { + PacketCoding.DecodePacket(string_91).require match { + case SquadMembershipResponse(unk1, unk2, unk3, unk4, unk5, unk6, unk7, unk8) => + unk1 mustEqual SquadResponseType.PlatoonReject + unk2 mustEqual 0 + unk3 mustEqual 0 + unk4 mustEqual 297025L + unk5.contains(41605002L) mustEqual true + unk6 mustEqual "" + unk7 mustEqual true + unk8.contains(None) mustEqual true + case _ => + ko + } + } + + "decode (9-2)" in { + PacketCoding.DecodePacket(string_92).require match { + case SquadMembershipResponse(unk1, unk2, unk3, unk4, unk5, unk6, unk7, unk8) => + unk1 mustEqual SquadResponseType.PlatoonReject + unk2 mustEqual 0 + unk3 mustEqual 0 + unk4 mustEqual 30910985L + unk5.contains(41578085L) mustEqual true + unk6 mustEqual "Wizkid45" + unk7 mustEqual false + unk8.contains(None) mustEqual true + case _ => + ko + } + } + + "decode (b-1)" in { + PacketCoding.DecodePacket(string_b1).require match { + case SquadMembershipResponse(unk1, unk2, unk3, unk4, unk5, unk6, unk7, unk8) => + unk1 mustEqual SquadResponseType.PlatoonLeave + unk2 mustEqual 0 + unk3 mustEqual 1 + unk4 mustEqual 30910985L + unk5.contains(41578085L) mustEqual true + unk6 mustEqual "SAraisVanu" + unk7 mustEqual false + unk8.contains(None) mustEqual true + case _ => + ko + } + } + + "encode (0-1)" in { + val msg = SquadMembershipResponse(SquadResponseType.Invite, 0, 0, 42771010L, Some(1300870L), "HofD", false, None) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string_01 + } + + "encode (0-2)" in { + val msg = SquadMembershipResponse(SquadResponseType.Invite, 0, 0, 1176612L, Some(1004937L), "SPOILERS", true, None) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string_02 + } + + "encode (1-1)" in { + val msg = SquadMembershipResponse(SquadResponseType.Unk01, 19, 0, 41530025L, Some(0L), "", true, Some(None)) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string_11 + } + + "encode (1-2)" in { + val msg = SquadMembershipResponse(SquadResponseType.Unk01, 18, 0, 41578085L, Some(0L), "", true, Some(None)) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string_12 + } + + "encode (2-1)" in { + val msg = SquadMembershipResponse(SquadResponseType.Accept, 0, 0, 1300870L, Some(42771010L), "VirusGiver", true, Some(None)) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string_21 + } + + "encode (2-2)" in { + val msg = SquadMembershipResponse(SquadResponseType.Accept, 0, 0, 1300870L, Some(30736877L), "VirusGiver", true, Some(None)) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string_22 + } + + "encode (3-1)" in { + val msg = SquadMembershipResponse(SquadResponseType.Reject, 0, 3, 31035057L, Some(42771010L), "HMFIC", false, Some(None)) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string_31 + } + + "encode (3-2)" in { + val msg = SquadMembershipResponse(SquadResponseType.Reject, 0, 2, 31106913L, Some(42771010L), "DAN1111", false, Some(None)) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string_32 + } + + "encode (4-1)" in { + val msg = SquadMembershipResponse(SquadResponseType.Cancel, 0, 2, 41578085L, Some(41607133L), "SAraisVanu", false, Some(None)) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string_41 + } + + "encode (4-2)" in { + val msg = SquadMembershipResponse(SquadResponseType.Cancel, 0, 2, 41607396L, Some(41324011L), "AirInjector", false, Some(None)) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string_42 + } + + "encode (5-1)" in { + val msg = SquadMembershipResponse(SquadResponseType.Leave, 0, 1, 1176612L, Some(1176612L), "CCRIDER", true, Some(None)) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string_51 + } + + "encode (7-1)" in { + val msg = SquadMembershipResponse(SquadResponseType.PlatoonInvite, 2, 0, 1176612L, Some(0L), "", true, Some(None)) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string_71 + } + + "encode (7-2)" in { + val msg = SquadMembershipResponse(SquadResponseType.PlatoonInvite, 0, 0, 41578085L, Some(30910985L), "Wizkid45", true, Some(None)) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string_72 + } + + "encode (8-1)" in { + val msg = SquadMembershipResponse(SquadResponseType.PlatoonAccept, 0, 0, 30910985L, Some(41578085L), "Wizkid45", false, Some(None)) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string_81 + } + + "encode (9-1)" in { + val msg = SquadMembershipResponse(SquadResponseType.PlatoonReject, 0, 0, 297025L, Some(41605002L), "", true, Some(None)) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string_91 + } + + "encode (9-2)" in { + val msg = SquadMembershipResponse(SquadResponseType.PlatoonReject, 0, 0, 30910985L, Some(41578085L), "Wizkid45", false, Some(None)) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string_92 + } + + "encode (b-1)" in { + val msg = SquadMembershipResponse(SquadResponseType.PlatoonLeave, 0, 1, 30910985L, Some(41578085L), "SAraisVanu", false, Some(None)) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string_b1 + } + } +} diff --git a/common/src/test/scala/game/SquadStateTest.scala b/common/src/test/scala/game/SquadStateTest.scala new file mode 100644 index 000000000..5cce7ae30 --- /dev/null +++ b/common/src/test/scala/game/SquadStateTest.scala @@ -0,0 +1,244 @@ +// Copyright (c) 2019 PSForever +package game + +import net.psforever.packet._ +import net.psforever.packet.game._ +import net.psforever.types.Vector3 +import org.specs2.mutable._ +import scodec.bits._ + +class SquadStateTest extends Specification { + val string1 = hex"770700186d9130081001b11b27c1c041680000" + val string2 = hex"770700242a28c020003e9237a90e3382695004eab58a0281017eb95613df4c42950040" + val stringx = hex"7704008dd9ccf010042a9837310e1b82a8c006646c7a028103984f34759c904a800014f01c26f3d014081ddd3896931bc25478037680ea80c081d699a147b01e154000031c0bc81407e08c1a3a890de1542c022070bd0140815958bf29efa6214300108023c01000ae491ac68d1a61342c023623c50140011d6ea0878f3026a00009e014" + + "decode (1)" in { + PacketCoding.DecodePacket(string1).require match { + case SquadState(guid, list) => + guid mustEqual PlanetSideGUID(7) + list.size mustEqual 1 + //0 + list.head match { + case SquadStateInfo(char_id, u2, u3, pos, u4, u5, u6, u7, u8, u9) => + char_id mustEqual 1300870L + u2 mustEqual 64 + u3 mustEqual 64 + pos mustEqual Vector3(3464.0469f, 4065.5703f, 20.015625f) + u4 mustEqual 2 + u5 mustEqual 2 + u6 mustEqual false + u7 mustEqual 0 + u8.isEmpty mustEqual true + u9.isEmpty mustEqual true + case _ => + ko + } + case _ => + ko + } + } + + "decode (2)" in { + PacketCoding.DecodePacket(string2).require match { + case SquadState(guid, list) => + guid mustEqual PlanetSideGUID(7) + list.size mustEqual 2 + //0 + list.head match { + case SquadStateInfo(char_id, u2, u3, pos, u4, u5, u6, u7, u8, u9) => + char_id mustEqual 42771010L + u2 mustEqual 0 + u3 mustEqual 0 + pos mustEqual Vector3(6801.953f, 4231.828f, 39.21875f) + u4 mustEqual 2 + u5 mustEqual 2 + u6 mustEqual false + u7 mustEqual 680 + u8.isEmpty mustEqual true + u9.isEmpty mustEqual true + case _ => + ko + } + list(1) match { + case SquadStateInfo(char_id, u2, u3, pos, u4, u5, u6, u7, u8, u9) => + char_id mustEqual 42644970L + u2 mustEqual 64 + u3 mustEqual 64 + pos mustEqual Vector3(2908.7422f, 3742.6875f, 67.296875f) + u4 mustEqual 2 + u5 mustEqual 2 + u6 mustEqual false + u7 mustEqual 680 + u8.isEmpty mustEqual true + u9.isEmpty mustEqual true + case _ => + ko + } + case _ => + ko + } + } + + "decode (8)" in { + PacketCoding.DecodePacket(stringx).require match { + case SquadState(guid, list) => + guid mustEqual PlanetSideGUID(4) + list.size mustEqual 8 + //0 + list.head match { + case SquadStateInfo(char_id, u2, u3, pos, u4, u5, u6, u7, u8, u9) => + char_id mustEqual 30383325L + u2 mustEqual 0 + u3 mustEqual 16 + pos mustEqual Vector3(6849.328f, 4231.5938f, 41.71875f) + u4 mustEqual 2 + u5 mustEqual 2 + u6 mustEqual false + u7 mustEqual 864 + u8.isEmpty mustEqual true + u9.isEmpty mustEqual true + case _ => + ko + } + list(1) match { + case SquadStateInfo(char_id, u2, u3, pos, u4, u5, u6, u7, u8, u9) => + char_id mustEqual 41577572L + u2 mustEqual 64 + u3 mustEqual 64 + pos mustEqual Vector3(6183.797f, 4013.6328f, 72.5625f) + u4 mustEqual 2 + u5 mustEqual 2 + u6 mustEqual false + u7 mustEqual 0 + u8.contains(335) mustEqual true + u9.contains(true) mustEqual true + case _ => + ko + } + list(2) match { + case SquadStateInfo(char_id, u2, u3, pos, u4, u5, u6, u7, u8, u9) => + char_id mustEqual 41606788L + u2 mustEqual 64 + u3 mustEqual 64 + pos mustEqual Vector3(6611.8594f, 4242.586f, 75.46875f) + u4 mustEqual 2 + u5 mustEqual 2 + u6 mustEqual false + u7 mustEqual 888 + u8.isEmpty mustEqual true + u9.isEmpty mustEqual true + case _ => + ko + } + list(3) match { + case SquadStateInfo(char_id, u2, u3, pos, u4, u5, u6, u7, u8, u9) => + char_id mustEqual 30736877L + u2 mustEqual 64 + u3 mustEqual 64 + pos mustEqual Vector3(6809.836f, 4218.078f, 40.234375f) + u4 mustEqual 2 + u5 mustEqual 2 + u6 mustEqual false + u7 mustEqual 0 + u8.isEmpty mustEqual true + u9.isEmpty mustEqual true + case _ => + ko + } + list(4) match { + case SquadStateInfo(char_id, u2, u3, pos, u4, u5, u6, u7, u8, u9) => + char_id mustEqual 41517411L + u2 mustEqual 64 + u3 mustEqual 63 + pos mustEqual Vector3(6848.0312f, 4232.2266f, 41.734375f) + u4 mustEqual 2 + u5 mustEqual 2 + u6 mustEqual false + u7 mustEqual 556 + u8.isEmpty mustEqual true + u9.isEmpty mustEqual true + case _ => + ko + } + list(5) match { + case SquadStateInfo(char_id, u2, u3, pos, u4, u5, u6, u7, u8, u9) => + char_id mustEqual 41607488L + u2 mustEqual 64 + u3 mustEqual 64 + pos mustEqual Vector3(2905.3438f, 3743.9453f, 67.296875f) + u4 mustEqual 2 + u5 mustEqual 2 + u6 mustEqual false + u7 mustEqual 304 + u8.isEmpty mustEqual true + u9.isEmpty mustEqual true + case _ => + ko + } + list(6) match { + case SquadStateInfo(char_id, u2, u3, pos, u4, u5, u6, u7, u8, u9) => + char_id mustEqual 41419792L + u2 mustEqual 0 + u3 mustEqual 5 + pos mustEqual Vector3(6800.8906f ,4236.7734f, 39.296875f) + u4 mustEqual 2 + u5 mustEqual 2 + u6 mustEqual false + u7 mustEqual 556 + u8.isEmpty mustEqual true + u9.isEmpty mustEqual true + case _ => + ko + } + list(7) match { + case SquadStateInfo(char_id, u2, u3, pos, u4, u5, u6, u7, u8, u9) => + char_id mustEqual 42616684L + u2 mustEqual 64 + u3 mustEqual 0 + pos mustEqual Vector3(2927.1094f, 3704.0312f, 78.375f) + u4 mustEqual 1 + u5 mustEqual 1 + u6 mustEqual false + u7 mustEqual 0 + u8.contains(572) mustEqual true + u9.contains(true) mustEqual true + case _ => + ko + } + case _ => + ko + } + } + + "encode (1)" in { + val msg = SquadState(PlanetSideGUID(7), List( + SquadStateInfo(1300870L, 64, 64, Vector3(3464.0469f, 4065.5703f, 20.015625f), 2, 2, false, 0) + )) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual string1 + } + + "encode (2)" in { + val msg = SquadState(PlanetSideGUID(7), List( + SquadStateInfo(42771010L, 0, 0, Vector3(6801.953f, 4231.828f, 39.21875f), 2, 2, false, 680), + SquadStateInfo(42644970L, 64, 64, Vector3(2908.7422f, 3742.6875f, 67.296875f), 2, 2, false, 680) + )) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual string2 + } + + "encode (8)" in { + val msg = SquadState(PlanetSideGUID(4), List( + SquadStateInfo(30383325L, 0, 16, Vector3(6849.328f, 4231.5938f, 41.71875f), 2, 2, false, 864), + SquadStateInfo(41577572L, 64, 64, Vector3(6183.797f, 4013.6328f, 72.5625f), 2, 2, false, 0, 335, true), + SquadStateInfo(41606788L, 64, 64, Vector3(6611.8594f, 4242.586f, 75.46875f), 2, 2, false, 888), + SquadStateInfo(30736877L, 64, 64, Vector3(6809.836f, 4218.078f, 40.234375f), 2, 2, false, 0), + SquadStateInfo(41517411L, 64, 63, Vector3(6848.0312f, 4232.2266f, 41.734375f), 2, 2, false, 556), + SquadStateInfo(41607488L, 64, 64, Vector3(2905.3438f, 3743.9453f, 67.296875f), 2, 2, false, 304), + SquadStateInfo(41419792L, 0, 5, Vector3(6800.8906f, 4236.7734f, 39.296875f), 2, 2, false, 556), + SquadStateInfo(42616684L, 64, 0, Vector3(2927.1094f, 3704.0312f, 78.375f), 1, 1, false, 0, 572, true) + )) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual stringx + } +} \ No newline at end of file diff --git a/common/src/test/scala/game/SquadWaypointEventTest.scala b/common/src/test/scala/game/SquadWaypointEventTest.scala index 3c2b8bb81..199b0bb1c 100644 --- a/common/src/test/scala/game/SquadWaypointEventTest.scala +++ b/common/src/test/scala/game/SquadWaypointEventTest.scala @@ -3,8 +3,8 @@ package game import org.specs2.mutable._ import net.psforever.packet._ -import net.psforever.packet.game.{SquadWaypointEvent, WaypointEvent} -import net.psforever.types.Vector3 +import net.psforever.packet.game.{SquadWaypointEvent, WaypointEvent, WaypointEventAction} +import net.psforever.types.{SquadWaypoints, Vector3} import scodec.bits._ class SquadWaypointEventTest extends Specification { @@ -16,12 +16,12 @@ class SquadWaypointEventTest extends Specification { "decode (1)" in { PacketCoding.DecodePacket(string_1).require match { case SquadWaypointEvent(unk1, unk2, unk3, unk4, unk5, unk6) => - unk1 mustEqual 2 + unk1 mustEqual WaypointEventAction.Remove unk2 mustEqual 11 unk3 mustEqual 31155863L - unk4 mustEqual 0 - unk5 mustEqual None - unk6 mustEqual None + unk4 mustEqual SquadWaypoints.One + unk5.isEmpty mustEqual true + unk6.isEmpty mustEqual true case _ => ko } @@ -30,12 +30,12 @@ class SquadWaypointEventTest extends Specification { "decode (2)" in { PacketCoding.DecodePacket(string_2).require match { case SquadWaypointEvent(unk1, unk2, unk3, unk4, unk5, unk6) => - unk1 mustEqual 2 + unk1 mustEqual WaypointEventAction.Remove unk2 mustEqual 10 unk3 mustEqual 0L - unk4 mustEqual 4 - unk5 mustEqual None - unk6 mustEqual None + unk4 mustEqual SquadWaypoints.ExperienceRally + unk5.isEmpty mustEqual true + unk6.isEmpty mustEqual true case _ => ko } @@ -44,12 +44,12 @@ class SquadWaypointEventTest extends Specification { "decode (3)" in { PacketCoding.DecodePacket(string_3).require match { case SquadWaypointEvent(unk1, unk2, unk3, unk4, unk5, unk6) => - unk1 mustEqual 0 + unk1 mustEqual WaypointEventAction.Add unk2 mustEqual 3 unk3 mustEqual 41581052L - unk4 mustEqual 1 - unk5 mustEqual None - unk6 mustEqual Some(WaypointEvent(10, Vector3(3457.9688f, 5514.4688f, 0.0f), 1)) + unk4 mustEqual SquadWaypoints.Two + unk5.isEmpty mustEqual true + unk6.contains( WaypointEvent(10, Vector3(3457.9688f, 5514.4688f, 0.0f), 1) ) mustEqual true case _ => ko } @@ -58,40 +58,40 @@ class SquadWaypointEventTest extends Specification { "decode (4)" in { PacketCoding.DecodePacket(string_4).require match { case SquadWaypointEvent(unk1, unk2, unk3, unk4, unk5, unk6) => - unk1 mustEqual 1 + unk1 mustEqual WaypointEventAction.Unknown1 unk2 mustEqual 3 unk3 mustEqual 41581052L - unk4 mustEqual 1 - unk5 mustEqual Some(4L) - unk6 mustEqual None + unk4 mustEqual SquadWaypoints.Two + unk5.contains( 4L ) mustEqual true + unk6.isEmpty mustEqual true case _ => ko } } "encode (1)" in { - val msg = SquadWaypointEvent(2, 11, 31155863L, 0) + val msg = SquadWaypointEvent.Remove(11, 31155863L, SquadWaypoints.One) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_1 } "encode (2)" in { - val msg = SquadWaypointEvent(2, 10, 0L, 4) + val msg = SquadWaypointEvent.Remove(10, 0L, SquadWaypoints.ExperienceRally) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_2 } "encode (3)" in { - val msg = SquadWaypointEvent(0, 3, 41581052L, 1, 10, Vector3(3457.9688f, 5514.4688f, 0.0f), 1) + val msg = SquadWaypointEvent.Add(3, 41581052L, SquadWaypoints.Two, WaypointEvent(10, Vector3(3457.9688f, 5514.4688f, 0.0f), 1)) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_3 } "encode (4)" in { - val msg = SquadWaypointEvent(1, 3, 41581052L, 1, 4L) + val msg = SquadWaypointEvent.Unknown1(3, 41581052L, SquadWaypoints.Two, 4L) val pkt = PacketCoding.EncodePacket(msg).require.toByteVector pkt mustEqual string_4 diff --git a/common/src/test/scala/game/objectcreate/CharacterDataTest.scala b/common/src/test/scala/game/objectcreate/CharacterDataTest.scala index eece51214..22a1e7db4 100644 --- a/common/src/test/scala/game/objectcreate/CharacterDataTest.scala +++ b/common/src/test/scala/game/objectcreate/CharacterDataTest.scala @@ -48,7 +48,7 @@ class CharacterDataTest extends Specification { a.data.v5.isEmpty mustEqual true a.exosuit mustEqual ExoSuitType.Reinforced a.unk5 mustEqual 0 - a.unk6 mustEqual 30777081L + a.char_id mustEqual 30777081L a.unk7 mustEqual 1 a.unk8 mustEqual 4 a.unk9 mustEqual 0 @@ -167,7 +167,7 @@ class CharacterDataTest extends Specification { a.data.v5.isEmpty mustEqual true a.exosuit mustEqual ExoSuitType.Reinforced a.unk5 mustEqual 0 - a.unk6 mustEqual 192L + a.char_id mustEqual 192L a.unk7 mustEqual 0 a.unk8 mustEqual 0 a.unk9 mustEqual 0 @@ -236,7 +236,7 @@ class CharacterDataTest extends Specification { a.data.v5.isEmpty mustEqual true a.exosuit mustEqual ExoSuitType.MAX a.unk5 mustEqual 1 - a.unk6 mustEqual 0L + a.char_id mustEqual 0L a.unk7 mustEqual 0 a.unk8 mustEqual 0 a.unk9 mustEqual 0 diff --git a/common/src/test/scala/game/objectcreatedetailed/DetailedCharacterDataTest.scala b/common/src/test/scala/game/objectcreatedetailed/DetailedCharacterDataTest.scala index e64dd8fcf..2843386de 100644 --- a/common/src/test/scala/game/objectcreatedetailed/DetailedCharacterDataTest.scala +++ b/common/src/test/scala/game/objectcreatedetailed/DetailedCharacterDataTest.scala @@ -76,7 +76,7 @@ class DetailedCharacterDataTest extends Specification { a.data.v5.isEmpty mustEqual true a.exosuit mustEqual ExoSuitType.Standard a.unk5 mustEqual 0 - a.unk6 mustEqual 41605313L + a.char_id mustEqual 41605313L a.unk7 mustEqual 0 a.unk8 mustEqual 0 a.unk9 mustEqual 0 @@ -264,7 +264,7 @@ class DetailedCharacterDataTest extends Specification { a.data.v5.isEmpty mustEqual true a.exosuit mustEqual ExoSuitType.Standard a.unk5 mustEqual 0 - a.unk6 mustEqual 192L + a.char_id mustEqual 192L a.unk7 mustEqual 0 a.unk8 mustEqual 0 a.unk9 mustEqual 0 @@ -449,7 +449,7 @@ class DetailedCharacterDataTest extends Specification { a.data.v5.isEmpty mustEqual true a.exosuit mustEqual ExoSuitType.MAX a.unk5 mustEqual 1 - a.unk6 mustEqual 41605870L + a.char_id mustEqual 41605870L a.unk7 mustEqual 0 a.unk8 mustEqual 0 a.unk9 mustEqual 0 @@ -657,7 +657,7 @@ class DetailedCharacterDataTest extends Specification { a.data.v5.isEmpty mustEqual true a.exosuit mustEqual ExoSuitType.Agile a.unk5 mustEqual 0 - a.unk6 mustEqual 733931L + a.char_id mustEqual 733931L a.unk7 mustEqual 0 a.unk8 mustEqual 0 a.unk9 mustEqual 0 @@ -1165,7 +1165,7 @@ class DetailedCharacterDataTest extends Specification { a.data.v5.isEmpty mustEqual true a.exosuit mustEqual ExoSuitType.Standard a.unk5 mustEqual 0 - a.unk6 mustEqual 1176612L + a.char_id mustEqual 1176612L a.unk7 mustEqual 15 a.unk8 mustEqual 5 a.unk9 mustEqual 10 @@ -1315,7 +1315,7 @@ class DetailedCharacterDataTest extends Specification { a.data.v5.isEmpty mustEqual true a.exosuit mustEqual ExoSuitType.Standard a.unk5 mustEqual 0 - a.unk6 mustEqual 1267466L + a.char_id mustEqual 1267466L a.unk7 mustEqual 3 a.unk8 mustEqual 3 a.unk9 mustEqual 0 diff --git a/common/src/test/scala/game/objectcreatevehicle/MountedVehiclesTest.scala b/common/src/test/scala/game/objectcreatevehicle/MountedVehiclesTest.scala index 5b73667f0..1c831f74b 100644 --- a/common/src/test/scala/game/objectcreatevehicle/MountedVehiclesTest.scala +++ b/common/src/test/scala/game/objectcreatevehicle/MountedVehiclesTest.scala @@ -62,7 +62,7 @@ class MountedVehiclesTest extends Specification { a.data.v5.isEmpty mustEqual true a.exosuit mustEqual ExoSuitType.Agile a.unk5 mustEqual 0 - a.unk6 mustEqual 30777081L + a.char_id mustEqual 30777081L a.unk7 mustEqual 1 a.unk8 mustEqual 4 a.unk9 mustEqual 0 diff --git a/common/src/test/scala/objects/AvatarTest.scala b/common/src/test/scala/objects/AvatarTest.scala index c2c482c06..6e1502b15 100644 --- a/common/src/test/scala/objects/AvatarTest.scala +++ b/common/src/test/scala/objects/AvatarTest.scala @@ -121,19 +121,19 @@ class AvatarTest extends Specification { obj.Implants(0).Active mustEqual false obj.Implants(0).Implant mustEqual ImplantType.None obj.Implant(0) mustEqual ImplantType.None - obj.Implants(0).Installed mustEqual None + obj.Implants(0).Installed.isEmpty mustEqual true obj.Implants(1).Unlocked mustEqual false obj.Implants(1).Initialized mustEqual false obj.Implants(1).Active mustEqual false obj.Implants(1).Implant mustEqual ImplantType.None obj.Implant(1) mustEqual ImplantType.None - obj.Implants(1).Installed mustEqual None + obj.Implants(1).Installed.isEmpty mustEqual true obj.Implants(2).Unlocked mustEqual false obj.Implants(2).Initialized mustEqual false obj.Implants(2).Active mustEqual false obj.Implants(2).Implant mustEqual ImplantType.None obj.Implant(2) mustEqual ImplantType.None - obj.Implants(2).Installed mustEqual None + obj.Implants(2).Installed.isEmpty mustEqual true obj.Implant(3) mustEqual ImplantType.None //invalid slots beyond the third always reports as ImplantType.None } @@ -142,10 +142,10 @@ class AvatarTest extends Specification { val testplant : ImplantDefinition = ImplantDefinition(1) val obj = Avatar("Chord", PlanetSideEmpire.TR, CharacterGender.Male, 0, CharacterVoice.Voice5) obj.Implants(0).Unlocked = true - obj.InstallImplant(testplant) mustEqual Some(0) + obj.InstallImplant(testplant).contains(0) mustEqual true obj.Implants.find({p => p.Implant == ImplantType(1)}) match { //find the installed implant case Some(slot) => - slot.Installed mustEqual Some(testplant) + slot.Installed.contains(testplant) mustEqual true case _ => ko } @@ -159,8 +159,8 @@ class AvatarTest extends Specification { obj.Implants(0).Unlocked = true obj.Implants(1).Unlocked = true - obj.InstallImplant(testplant1) mustEqual Some(0) - obj.InstallImplant(testplant2) mustEqual Some(1) + obj.InstallImplant(testplant1).contains(0) mustEqual true + obj.InstallImplant(testplant2).contains(1) mustEqual true } "can not install the same type of implant twice" in { @@ -170,8 +170,8 @@ class AvatarTest extends Specification { obj.Implants(0).Unlocked = true obj.Implants(1).Unlocked = true - obj.InstallImplant(testplant1) mustEqual Some(0) - obj.InstallImplant(testplant2) mustEqual None + obj.InstallImplant(testplant1).contains(0) mustEqual true + obj.InstallImplant(testplant2).isEmpty mustEqual true } "can not install more implants than slots available (two unlocked)" in { @@ -182,9 +182,9 @@ class AvatarTest extends Specification { obj.Implants(0).Unlocked = true obj.Implants(1).Unlocked = true - obj.InstallImplant(testplant1) mustEqual Some(0) - obj.InstallImplant(testplant2) mustEqual Some(1) - obj.InstallImplant(testplant3) mustEqual None + obj.InstallImplant(testplant1).contains(0) mustEqual true + obj.InstallImplant(testplant2).contains(1) mustEqual true + obj.InstallImplant(testplant3).isEmpty mustEqual true } "can not install more implants than slots available (four implants)" in { @@ -197,21 +197,21 @@ class AvatarTest extends Specification { obj.Implants(1).Unlocked = true obj.Implants(2).Unlocked = true - obj.InstallImplant(testplant1) mustEqual Some(0) - obj.InstallImplant(testplant2) mustEqual Some(1) - obj.InstallImplant(testplant3) mustEqual Some(2) - obj.InstallImplant(testplant4) mustEqual None + obj.InstallImplant(testplant1).contains(0) mustEqual true + obj.InstallImplant(testplant2).contains(1) mustEqual true + obj.InstallImplant(testplant3).contains(2) mustEqual true + obj.InstallImplant(testplant4).isEmpty mustEqual true } "can uninstall an implant" in { val testplant : ImplantDefinition = ImplantDefinition(1) val obj = Avatar("Chord", PlanetSideEmpire.TR, CharacterGender.Male, 0, CharacterVoice.Voice5) obj.Implants(0).Unlocked = true - obj.InstallImplant(testplant) mustEqual Some(0) - obj.Implants(0).Installed mustEqual Some(testplant) + obj.InstallImplant(testplant).contains(0) mustEqual true + obj.Implants(0).Installed.contains(testplant) mustEqual true - obj.UninstallImplant(testplant.Type) mustEqual Some(0) - obj.Implants(0).Installed mustEqual None + obj.UninstallImplant(testplant.Type).contains(0) mustEqual true + obj.Implants(0).Installed.isEmpty mustEqual true } "can uninstall just a specific implant" in { @@ -222,14 +222,14 @@ class AvatarTest extends Specification { obj.Implants(0).Unlocked = true obj.Implants(1).Unlocked = true obj.Implants(2).Unlocked = true - obj.InstallImplant(testplant1) mustEqual Some(0) - obj.InstallImplant(testplant2) mustEqual Some(1) - obj.InstallImplant(testplant3) mustEqual Some(2) + obj.InstallImplant(testplant1).contains(0) mustEqual true + obj.InstallImplant(testplant2).contains(1) mustEqual true + obj.InstallImplant(testplant3).contains(2) mustEqual true obj.Implant(0) mustEqual testplant1.Type obj.Implant(1) mustEqual testplant2.Type obj.Implant(2) mustEqual testplant3.Type - obj.UninstallImplant(testplant2.Type) mustEqual Some(1) + obj.UninstallImplant(testplant2.Type).contains(1) mustEqual true obj.Implant(0) mustEqual testplant1.Type obj.Implant(1) mustEqual ImplantType.None obj.Implant(2) mustEqual testplant3.Type @@ -243,16 +243,16 @@ class AvatarTest extends Specification { obj.Implants(0).Unlocked = true obj.Implants(1).Unlocked = true obj.Implants(2).Unlocked = true - obj.InstallImplant(testplant1) mustEqual Some(0) - obj.InstallImplant(testplant2) mustEqual Some(1) - obj.InstallImplant(testplant3) mustEqual Some(2) - obj.UninstallImplant(testplant2.Type) mustEqual Some(1) + obj.InstallImplant(testplant1).contains(0) mustEqual true + obj.InstallImplant(testplant2).contains(1) mustEqual true + obj.InstallImplant(testplant3).contains(2) mustEqual true + obj.UninstallImplant(testplant2.Type).contains(1) mustEqual true obj.Implant(0) mustEqual testplant1.Type obj.Implant(1) mustEqual ImplantType.None obj.Implant(2) mustEqual testplant3.Type val testplant4 : ImplantDefinition = ImplantDefinition(4) - obj.InstallImplant(testplant4) mustEqual Some(1) + obj.InstallImplant(testplant4).contains(1) mustEqual true obj.Implant(0) mustEqual testplant1.Type obj.Implant(1) mustEqual testplant4.Type obj.Implant(2) mustEqual testplant3.Type @@ -264,8 +264,8 @@ class AvatarTest extends Specification { val obj = Avatar("Chord", PlanetSideEmpire.TR, CharacterGender.Male, 0, CharacterVoice.Voice5) obj.Implants(0).Unlocked = true obj.Implants(1).Unlocked = true - obj.InstallImplant(testplant1) mustEqual Some(0) - obj.InstallImplant(testplant2) mustEqual Some(1) + obj.InstallImplant(testplant1).contains(0) mustEqual true + obj.InstallImplant(testplant2).contains(1) mustEqual true obj.Implants(0).Initialized = true obj.Implants(0).Active = true obj.Implants(1).Initialized = true @@ -281,7 +281,7 @@ class AvatarTest extends Specification { "does not have any loadout specifications by default" in { val (_, avatar) = CreatePlayer() - (0 to 9).foreach { avatar.LoadLoadout(_) mustEqual None } + (0 to 9).foreach { avatar.EquipmentLoadouts.LoadLoadout(_).isEmpty mustEqual true } ok } @@ -289,9 +289,9 @@ class AvatarTest extends Specification { val (obj, avatar) = CreatePlayer() obj.Slot(0).Equipment.get.asInstanceOf[Tool].Magazine = 1 //non-standard but legal obj.Slot(2).Equipment.get.asInstanceOf[Tool].AmmoSlot.Magazine = 100 //non-standard (and out of range, real=25) - avatar.SaveLoadout(obj, "test", 0) + avatar.EquipmentLoadouts.SaveLoadout(obj, "test", 0) - avatar.LoadLoadout(0) match { + avatar.EquipmentLoadouts.LoadLoadout(0) match { case Some(items : InfantryLoadout) => items.label mustEqual "test" items.exosuit mustEqual obj.ExoSuit @@ -329,25 +329,25 @@ class AvatarTest extends Specification { "save player's current inventory as a loadout, only found in the called-out slot number" in { val (obj, avatar) = CreatePlayer() - avatar.SaveLoadout(obj, "test", 0) + avatar.EquipmentLoadouts.SaveLoadout(obj, "test", 0) - avatar.LoadLoadout(1).isDefined mustEqual false - avatar.LoadLoadout(0).isDefined mustEqual true + avatar.EquipmentLoadouts.LoadLoadout(1).isDefined mustEqual false + avatar.EquipmentLoadouts.LoadLoadout(0).isDefined mustEqual true } "try to save player's current inventory as a loadout, but will not save to an invalid slot" in { val (obj, avatar) = CreatePlayer() - avatar.SaveLoadout(obj, "test", 10) + avatar.EquipmentLoadouts.SaveLoadout(obj, "test", 50) - avatar.LoadLoadout(10) mustEqual None + avatar.EquipmentLoadouts.LoadLoadout(50).isEmpty mustEqual true } "save player's current inventory as a loadout, without inventory contents" in { val (obj, avatar) = CreatePlayer() obj.Inventory.Clear() - avatar.SaveLoadout(obj, "test", 0) + avatar.EquipmentLoadouts.SaveLoadout(obj, "test", 0) - avatar.LoadLoadout(0) match { + avatar.EquipmentLoadouts.LoadLoadout(0) match { case Some(items : InfantryLoadout) => items.label mustEqual "test" items.exosuit mustEqual obj.ExoSuit @@ -364,9 +364,9 @@ class AvatarTest extends Specification { obj.Slot(0).Equipment = None obj.Slot(2).Equipment = None obj.Slot(4).Equipment = None - avatar.SaveLoadout(obj, "test", 0) + avatar.EquipmentLoadouts.SaveLoadout(obj, "test", 0) - avatar.LoadLoadout(0) match { + avatar.EquipmentLoadouts.LoadLoadout(0) match { case Some(items : InfantryLoadout) => items.label mustEqual "test" items.exosuit mustEqual obj.ExoSuit @@ -380,11 +380,11 @@ class AvatarTest extends Specification { "save, load, delete; rapidly" in { val (obj, avatar) = CreatePlayer() - avatar.SaveLoadout(obj, "test", 0) + avatar.EquipmentLoadouts.SaveLoadout(obj, "test", 0) - avatar.LoadLoadout(0).isDefined mustEqual true - avatar.DeleteLoadout(0) - avatar.LoadLoadout(0) mustEqual None + avatar.EquipmentLoadouts.LoadLoadout(0).isDefined mustEqual true + avatar.EquipmentLoadouts.DeleteLoadout(0) + avatar.EquipmentLoadouts.LoadLoadout(0).isEmpty mustEqual true } "the fifth slot is the locker wrapped in an EquipmentSlot" in { diff --git a/common/src/test/scala/objects/DamageModelTests.scala b/common/src/test/scala/objects/DamageModelTests.scala index 1230b0438..5a477c881 100644 --- a/common/src/test/scala/objects/DamageModelTests.scala +++ b/common/src/test/scala/objects/DamageModelTests.scala @@ -70,11 +70,11 @@ class DamageCalculationsTests extends Specification { } "calculate distance between target and source" in { - DistanceBetweenTargetandSource(resprojectile) mustEqual 10 + DistanceBetweenTargetandSource(resprojectile) mustEqual 67.38225f } "calculate distance between target and explosion (splash)" in { - DistanceFromExplosionToTarget(resprojectile) mustEqual 64.03124f + DistanceFromExplosionToTarget(resprojectile) mustEqual 63.031242f } "calculate no damage from components" in { diff --git a/common/src/test/scala/objects/PlayerTest.scala b/common/src/test/scala/objects/PlayerTest.scala index c1471800e..f579e4fc6 100644 --- a/common/src/test/scala/objects/PlayerTest.scala +++ b/common/src/test/scala/objects/PlayerTest.scala @@ -245,34 +245,34 @@ class PlayerTest extends Specification { val obj = TestPlayer("Chord", PlanetSideEmpire.TR, CharacterGender.Male, 0, CharacterVoice.Voice5) obj.Slot(-1).Equipment = wep - obj.Slot(-1).Equipment mustEqual None + obj.Slot(-1).Equipment.isEmpty mustEqual true } "search for the smallest available slot in which to store equipment" in { val obj = TestPlayer("Chord", PlanetSideEmpire.TR, CharacterGender.Male, 0, CharacterVoice.Voice5) obj.Inventory.Resize(3,3) //fits one item - obj.Fit(Tool(GlobalDefinitions.beamer)) mustEqual Some(0) + obj.Fit(Tool(GlobalDefinitions.beamer)).contains(0) mustEqual true - obj.Fit(Tool(GlobalDefinitions.suppressor)) mustEqual Some(2) + obj.Fit(Tool(GlobalDefinitions.suppressor)).contains(2) mustEqual true val ammo = AmmoBox(GlobalDefinitions.bullet_9mm) val ammo2 = AmmoBox(GlobalDefinitions.bullet_9mm) val ammo3 = AmmoBox(GlobalDefinitions.bullet_9mm) - obj.Fit(ammo) mustEqual Some(6) + obj.Fit(ammo).contains(6) mustEqual true obj.Slot(6).Equipment = ammo - obj.Fit(ammo2) mustEqual Some(Player.FreeHandSlot) + obj.Fit(ammo2).contains(Player.FreeHandSlot) mustEqual true obj.Slot(Player.FreeHandSlot).Equipment = ammo2 - obj.Fit(ammo3) mustEqual None + obj.Fit(ammo3).isEmpty mustEqual true } "can use their free hand to hold things" in { val obj = TestPlayer("Chord", PlanetSideEmpire.TR, CharacterGender.Male, 0, CharacterVoice.Voice5) val ammo = AmmoBox(GlobalDefinitions.bullet_9mm) - obj.FreeHand.Equipment mustEqual None + obj.FreeHand.Equipment.isEmpty mustEqual true obj.FreeHand.Equipment = ammo - obj.FreeHand.Equipment mustEqual Some(ammo) + obj.FreeHand.Equipment.contains(ammo) mustEqual true } "can access the player's locker-space" in { @@ -308,12 +308,12 @@ class PlayerTest extends Specification { item } - obj.Find(PlanetSideGUID(1)) mustEqual Some(0) //holsters - obj.Find(PlanetSideGUID(2)) mustEqual Some(4) //holsters, melee - obj.Find(PlanetSideGUID(3)) mustEqual Some(6) //inventory - obj.Find(PlanetSideGUID(4)) mustEqual None //can not find in locker-space - obj.Find(PlanetSideGUID(5)) mustEqual Some(Player.FreeHandSlot) //free hand - obj.Find(PlanetSideGUID(6)) mustEqual None //not here + obj.Find(PlanetSideGUID(1)).contains(0) mustEqual true //holsters + obj.Find(PlanetSideGUID(2)).contains(4) mustEqual true //holsters, melee + obj.Find(PlanetSideGUID(3)).contains(6) mustEqual true //inventory + obj.Find(PlanetSideGUID(4)).isEmpty mustEqual true //can not find in locker-space + obj.Find(PlanetSideGUID(5)).contains(Player.FreeHandSlot) mustEqual true //free hand + obj.Find(PlanetSideGUID(6)).isEmpty mustEqual true //not here } "does equipment collision checking (are we already holding something there?)" in { @@ -437,20 +437,20 @@ class PlayerTest extends Specification { "seat in a vehicle" in { val obj = TestPlayer("Chord", PlanetSideEmpire.TR, CharacterGender.Male, 0, CharacterVoice.Voice5) - obj.VehicleSeated mustEqual None + obj.VehicleSeated.isEmpty mustEqual true obj.VehicleSeated = PlanetSideGUID(65) - obj.VehicleSeated mustEqual Some(PlanetSideGUID(65)) + obj.VehicleSeated.contains(PlanetSideGUID(65)) mustEqual true obj.VehicleSeated = None - obj.VehicleSeated mustEqual None + obj.VehicleSeated.isEmpty mustEqual true } "own in a vehicle" in { val obj = TestPlayer("Chord", PlanetSideEmpire.TR, CharacterGender.Male, 0, CharacterVoice.Voice5) - obj.VehicleOwned mustEqual None + obj.VehicleOwned.isEmpty mustEqual true obj.VehicleOwned = PlanetSideGUID(65) - obj.VehicleOwned mustEqual Some(PlanetSideGUID(65)) + obj.VehicleOwned.contains(PlanetSideGUID(65)) mustEqual true obj.VehicleOwned = None - obj.VehicleOwned mustEqual None + obj.VehicleOwned.isEmpty mustEqual true } "remember what zone he is in" in { diff --git a/common/src/test/scala/objects/terminal/OrderTerminalTest.scala b/common/src/test/scala/objects/terminal/OrderTerminalTest.scala index 34b21670b..c60e1c17f 100644 --- a/common/src/test/scala/objects/terminal/OrderTerminalTest.scala +++ b/common/src/test/scala/objects/terminal/OrderTerminalTest.scala @@ -87,7 +87,7 @@ class OrderTerminalTest extends Specification { player.ExoSuit = ExoSuitType.Agile player.Slot(0).Equipment = Tool(GlobalDefinitions.beamer) player.Slot(6).Equipment = Tool(GlobalDefinitions.beamer) - avatar.SaveLoadout(player, "test", 0) + avatar.EquipmentLoadouts.SaveLoadout(player, "test", 0) val msg = infantryTerminal.Request(player, ItemTransactionMessage(PlanetSideGUID(10), TransactionType.Loadout, 4, "", 0, PlanetSideGUID(0))) msg.isInstanceOf[Terminal.InfantryLoadout] mustEqual true @@ -137,7 +137,7 @@ class OrderTerminalTest extends Specification { "player can retrieve a vehicle loadout" in { val fury = Vehicle(GlobalDefinitions.fury) fury.Slot(30).Equipment = AmmoBox(GlobalDefinitions.hellfire_ammo) - avatar.SaveLoadout(fury, "test", 10) + avatar.EquipmentLoadouts.SaveLoadout(fury, "test", 10) val msg = ItemTransactionMessage(PlanetSideGUID(1), TransactionType.Loadout, 4, "test", 0, PlanetSideGUID(0)) terminal.Request(player, msg) match { diff --git a/pslogin/src/main/scala/PsLogin.scala b/pslogin/src/main/scala/PsLogin.scala index 30eaca8ed..cfc0c1642 100644 --- a/pslogin/src/main/scala/PsLogin.scala +++ b/pslogin/src/main/scala/PsLogin.scala @@ -22,6 +22,7 @@ import services.ServiceManager import services.avatar._ import services.galaxy.GalaxyService import services.local._ +import services.teamwork.SquadService import services.vehicle.VehicleService import scala.collection.JavaConverters._ @@ -258,6 +259,7 @@ object PsLogin { serviceManager ! ServiceManager.Register(Props[LocalService], "local") serviceManager ! ServiceManager.Register(Props[VehicleService], "vehicle") serviceManager ! ServiceManager.Register(Props[GalaxyService], "galaxy") + serviceManager ! ServiceManager.Register(Props[SquadService], "squad") serviceManager ! ServiceManager.Register(Props(classOf[InterstellarCluster], continentList), "cluster") //attach event bus entry point to each zone diff --git a/pslogin/src/main/scala/WorldSessionActor.scala b/pslogin/src/main/scala/WorldSessionActor.scala index e09979db0..82fc7ec87 100644 --- a/pslogin/src/main/scala/WorldSessionActor.scala +++ b/pslogin/src/main/scala/WorldSessionActor.scala @@ -40,6 +40,7 @@ import net.psforever.objects.serverobject.terminals._ import net.psforever.objects.serverobject.terminals.Terminal.TerminalMessage import net.psforever.objects.serverobject.tube.SpawnTube import net.psforever.objects.serverobject.turret.{FacilityTurret, TurretUpgrade, WeaponTurret} +import net.psforever.objects.teamwork.Squad import net.psforever.objects.vehicles.{AccessPermissionGroup, Cargo, Utility, VehicleLockState, _} import net.psforever.objects.vital._ import net.psforever.objects.zones.{InterstellarCluster, Zone, ZoneHotSpotProjector} @@ -52,7 +53,9 @@ import services.galaxy.{GalaxyResponse, GalaxyServiceResponse} import services.local.{LocalAction, LocalResponse, LocalServiceMessage, LocalServiceResponse} import services.vehicle.support.TurretUpgrader import services.vehicle.{VehicleAction, VehicleResponse, VehicleServiceMessage, VehicleServiceResponse} +import services.teamwork.{SquadAction => SquadServiceAction, SquadServiceMessage, SquadServiceResponse, SquadResponse, SquadService} +import scala.collection.mutable.LongMap import scala.concurrent.duration._ import scala.concurrent.ExecutionContext.Implicits.global import scala.annotation.tailrec @@ -76,6 +79,7 @@ class WorldSessionActor extends Actor with MDCContextAware { var localService : ActorRef = ActorRef.noSender var vehicleService : ActorRef = ActorRef.noSender var galaxyService : ActorRef = ActorRef.noSender + var squadService : ActorRef = ActorRef.noSender var taskResolver : ActorRef = Actor.noSender var cluster : ActorRef = Actor.noSender var continent : Zone = Zone.Nowhere @@ -97,6 +101,7 @@ class WorldSessionActor extends Actor with MDCContextAware { var whenUsedLastKit : Long = 0 val projectiles : Array[Option[Projectile]] = Array.fill[Option[Projectile]](Projectile.RangeUID - Projectile.BaseUID)(None) var drawDeloyableIcon : PlanetSideGameObject with Deployable => Unit = RedrawDeployableIcons + var updateSquad : () => Unit = NoSquadUpdates var recentTeleportAttempt : Long = 0 var lastTerminalOrderFulfillment : Boolean = true /** * used during zone transfers to maintain reference to seated vehicle (which does not yet exist in the new zone) @@ -113,6 +118,21 @@ class WorldSessionActor extends Actor with MDCContextAware { * no harm should come from leaving the field set to an old unique identifier value after the transfer period */ var interstellarFerryTopLevelGUID : Option[PlanetSideGUID] = None + val squadUI : LongMap[SquadUIElement] = new LongMap[SquadUIElement]() + var squad_supplement_id : Int = 0 + /** + * When joining or creating a squad, the original state of the avatar's internal LFS variable is blanked. + * This `WSA`-local variable is then used to indicate the ongoing state of the LFS UI component, + * now called "Looking for Squad Member." + * Only the squad leader may toggle the LFSM marquee. + * Upon leaving or disbanding a squad, this value is made false. + * Control switching between the `Avatar`-local and the `WorldSessionActor`-local variable is contingent on `squadUI` being populated. + */ + var lfsm : Boolean = false + var squadChannel : Option[String] = None + var squadSetup : () => Unit = FirstTimeSquadSetup + var squadUpdateCounter : Int = 0 + val queuedSquadActions : Seq[() => Unit] = Seq(SquadUpdates, NoSquadUpdates, NoSquadUpdates, NoSquadUpdates) var amsSpawnPoints : List[SpawnPoint] = Nil var clientKeepAlive : Cancellable = DefaultCancellable.obj @@ -144,6 +164,7 @@ class WorldSessionActor extends Actor with MDCContextAware { galaxyService ! Service.Leave() LivePlayerList.Remove(sessionId) if(player != null && player.HasGUID) { + squadService ! Service.Leave(Some(player.CharId.toString)) val player_guid = player.GUID //handle orphaned deployables DisownDeployables() @@ -155,6 +176,7 @@ class WorldSessionActor extends Actor with MDCContextAware { .collect { case ((index, Some(obj))) => InventoryItem(obj, index) } ) ++ player.Inventory.Items) .filterNot({ case InventoryItem(obj, _) => obj.isInstanceOf[BoomerTrigger] || obj.isInstanceOf[Telepad] }) + //put any temporary value back into the avatar //TODO final character save before doing any of this (use equipment) continent.Population ! Zone.Population.Release(avatar) if(player.isAlive) { @@ -266,6 +288,7 @@ class WorldSessionActor extends Actor with MDCContextAware { ServiceManager.serviceManager ! Lookup("taskResolver") ServiceManager.serviceManager ! Lookup("cluster") ServiceManager.serviceManager ! Lookup("galaxy") + ServiceManager.serviceManager ! Lookup("squad") case _ => log.error("Unknown message") @@ -291,6 +314,9 @@ class WorldSessionActor extends Actor with MDCContextAware { case ServiceManager.LookupResult("cluster", endpoint) => cluster = endpoint log.info("ID: " + sessionId + " Got cluster service " + endpoint) + case ServiceManager.LookupResult("squad", endpoint) => + squadService = endpoint + log.info("ID: " + sessionId + " Got squad service " + endpoint) case ControlPacket(_, ctrl) => handleControlPkt(ctrl) @@ -336,6 +362,231 @@ class WorldSessionActor extends Actor with MDCContextAware { case VehicleServiceResponse(toChannel, guid, reply) => HandleVehicleServiceResponse(toChannel, guid, reply) + case SquadServiceResponse(_, excluded, response) => + if(!excluded.exists(_ == avatar.CharId)) { + response match { + case SquadResponse.ListSquadFavorite(line, task) => + sendResponse(SquadDefinitionActionMessage(PlanetSideGUID(0), line, SquadAction.ListSquadFavorite(task))) + + case SquadResponse.InitList(infos) => + sendResponse(ReplicationStreamMessage(infos)) + + case SquadResponse.UpdateList(infos) if infos.nonEmpty => + sendResponse( + ReplicationStreamMessage(6, None, + infos.map { case (index, squadInfo) => + SquadListing(index, squadInfo) + }.toVector + ) + ) + + case SquadResponse.RemoveFromList(infos) if infos.nonEmpty => + sendResponse( + ReplicationStreamMessage(1, None, + infos.map { index => + SquadListing(index, None) + }.toVector + ) + ) + + case SquadResponse.Detail(guid, detail) => + sendResponse(SquadDetailDefinitionUpdateMessage(guid, detail)) + + case SquadResponse.AssociateWithSquad(squad_guid) => + sendResponse(SquadDefinitionActionMessage(squad_guid, 0, SquadAction.AssociateWithSquad())) + + case SquadResponse.SetListSquad(squad_guid) => + sendResponse(SquadDefinitionActionMessage(squad_guid, 0, SquadAction.SetListSquad())) + + case SquadResponse.Membership(request_type, unk1, unk2, char_id, opt_char_id, player_name, unk5, unk6) => + val name = request_type match { + case SquadResponseType.Invite if unk5 => + //player_name is our name; the name of the player indicated by unk3 is needed + LivePlayerList.WorldPopulation({ case (_, a : Avatar) => char_id == a.CharId }).headOption match { + case Some(player) => + player.name + case None => + player_name + } + case _ => + player_name + } + sendResponse(SquadMembershipResponse(request_type, unk1, unk2, char_id, opt_char_id, name, unk5, unk6)) + + case SquadResponse.WantsSquadPosition(_, name) => + sendResponse( + ChatMsg( + ChatMessageType.CMT_SQUAD, true, name, + s"\\#6 would like to join your squad. (respond with \\#3/accept\\#6 or \\#3/reject\\#6)", + None + ) + ) + + case SquadResponse.Join(squad, positionsToUpdate, toChannel) => + val leader = squad.Leader + val membershipPositions = positionsToUpdate map squad.Membership.zipWithIndex + StartBundlingPackets() + membershipPositions.find({ case(member, _) => member.CharId == avatar.CharId }) match { + case Some((ourMember, ourIndex)) => + //we are joining the squad + //load each member's entry (our own too) + squad_supplement_id = squad.GUID.guid + 1 + membershipPositions.foreach { case(member, index) => + sendResponse(SquadMemberEvent.Add(squad_supplement_id, member.CharId, index, member.Name, member.ZoneId, unk7 = 0)) + squadUI(member.CharId) = SquadUIElement(member.Name, index, member.ZoneId, member.Health, member.Armor, member.Position) + } + //repeat our entry + sendResponse(SquadMemberEvent.Add(squad_supplement_id, ourMember.CharId, ourIndex, ourMember.Name, ourMember.ZoneId, unk7 = 0)) //repeat of our entry + val playerGuid = player.GUID + //turn lfs off + val factionOnContinentChannel = s"${continent.Id}/${player.Faction}" + if(avatar.LFS) { + avatar.LFS = false + sendResponse(PlanetsideAttributeMessage(playerGuid, 53, 0)) + avatarService ! AvatarServiceMessage(factionOnContinentChannel, AvatarAction.PlanetsideAttribute(playerGuid, 53, 0)) + } + //squad colors + GiveSquadColorsInZone() + avatarService ! AvatarServiceMessage(factionOnContinentChannel, AvatarAction.PlanetsideAttribute(playerGuid, 31, squad_supplement_id)) + //associate with member position in squad + sendResponse(PlanetsideAttributeMessage(playerGuid, 32, ourIndex)) + //a finalization? what does this do? + sendResponse(SquadDefinitionActionMessage(squad.GUID, 0, SquadAction.Unknown(18))) + updateSquad = PeriodicUpdatesWhenEnrolledInSquad + squadChannel = Some(toChannel) + case _ => + //other player is joining our squad + //load each member's entry + GiveSquadColorsInZone( + membershipPositions.map { case(member, index) => + val charId = member.CharId + sendResponse(SquadMemberEvent.Add(squad_supplement_id, charId, index, member.Name, member.ZoneId, unk7 = 0)) + squadUI(charId) = SquadUIElement(member.Name, index, member.ZoneId, member.Health, member.Armor, member.Position) + charId + } + ) + } + StopBundlingPackets() + //send an initial dummy update for map icon(s) + sendResponse(SquadState(PlanetSideGUID(squad_supplement_id), + membershipPositions + .filterNot { case (member, _) => member.CharId == avatar.CharId } + .map{ case (member, _) => SquadStateInfo(member.CharId, member.Health, member.Armor, member.Position, 2,2, false, 429, None,None) } + .toList + )) + + case SquadResponse.Leave(squad, positionsToUpdate) => + StartBundlingPackets() + positionsToUpdate.find({ case(member, _) => member == avatar.CharId }) match { + case Some((ourMember, ourIndex)) => + //we are leaving the squad + //remove each member's entry (our own too) + positionsToUpdate.foreach { case(member, index) => + sendResponse(SquadMemberEvent.Remove(squad_supplement_id, member, index)) + squadUI.remove(member) + } + //uninitialize + val playerGuid = player.GUID + sendResponse(SquadMemberEvent.Remove(squad_supplement_id, ourMember, ourIndex)) //repeat of our entry + sendResponse(PlanetsideAttributeMessage(playerGuid, 31, 0)) //disassociate with squad? + avatarService ! AvatarServiceMessage(s"${continent.Id}/${player.Faction}", AvatarAction.PlanetsideAttribute(playerGuid, 31, 0)) + sendResponse(PlanetsideAttributeMessage(playerGuid, 32, 0)) //disassociate with member position in squad? + sendResponse(PlanetsideAttributeMessage(playerGuid, 34, 4294967295L)) //unknown, perhaps unrelated? + lfsm = false + //a finalization? what does this do? + sendResponse(SquadDefinitionActionMessage(PlanetSideGUID(0), 0, SquadAction.Unknown(18))) + squad_supplement_id = 0 + squadUpdateCounter = 0 + updateSquad = NoSquadUpdates + squadChannel = None + case _ => + //remove each member's entry + GiveSquadColorsInZone( + positionsToUpdate.map { case(member, index) => + sendResponse(SquadMemberEvent.Remove(squad_supplement_id, member, index)) + squadUI.remove(member) + member + }, + value = 0 + ) + } + StopBundlingPackets() + + case SquadResponse.AssignMember(squad, from_index, to_index) => + //we've already swapped position internally; now we swap the cards + SwapSquadUIElements(squad, from_index, to_index) + + case SquadResponse.PromoteMember(squad, char_id, from_index, to_index) => + val charId = player.CharId + val guid = player.GUID + lazy val factionOnContinentChannel = s"${continent.Id}/${player.Faction}" + //are we being demoted? + if(squadUI(charId).index == 0) { + //lfsm -> lfs + if(lfsm) { + sendResponse(PlanetsideAttributeMessage(guid, 53, 0)) + avatarService ! AvatarServiceMessage(factionOnContinentChannel, AvatarAction.PlanetsideAttribute(guid, 53, 0)) + } + lfsm = false + sendResponse(PlanetsideAttributeMessage(guid, 32, from_index)) //associate with member position in squad + } + //are we being promoted? + else if(charId == char_id) { + sendResponse(PlanetsideAttributeMessage(guid, 32, 0)) //associate with member position in squad + } + avatarService ! AvatarServiceMessage(factionOnContinentChannel, AvatarAction.PlanetsideAttribute(guid, 31, squad_supplement_id)) + //we must fix the squad cards backend + SwapSquadUIElements(squad, from_index, to_index) + + case SquadResponse.UpdateMembers(squad, positions) => + val pairedEntries = positions.collect { + case entry if squadUI.contains(entry.char_id) => + (entry, squadUI(entry.char_id)) + } + //prune entries + val updatedEntries = pairedEntries + .collect({ + case (entry, element) if entry.zone_number != element.zone => + //zone gets updated for these entries + sendResponse(SquadMemberEvent.UpdateZone(squad_supplement_id, entry.char_id, element.index, entry.zone_number)) + squadUI(entry.char_id) = SquadUIElement(element.name, element.index, entry.zone_number, entry.health, entry.armor, entry.pos) + entry + case (entry, element) if entry.health != element.health || entry.armor != element.armor || entry.pos != element.position => + //other elements that need to be updated + squadUI(entry.char_id) = SquadUIElement(element.name, element.index, entry.zone_number, entry.health, entry.armor, entry.pos) + entry + }) + .filterNot(_.char_id == avatar.CharId) //we want to update our backend, but not our frontend + if(updatedEntries.nonEmpty) { + sendResponse( + SquadState( + PlanetSideGUID(squad_supplement_id), + updatedEntries.map { entry => SquadStateInfo(entry.char_id, entry.health, entry.armor, entry.pos, 2,2, false, 429, None,None)} + ) + ) + } + + case SquadResponse.SquadSearchResults() => + //I don't actually know how to return search results + sendResponse(SquadDefinitionActionMessage(PlanetSideGUID(0), 0, SquadAction.NoSquadSearchResults())) + + case SquadResponse.InitWaypoints(char_id, waypoints) => + StartBundlingPackets() + waypoints.foreach { case (waypoint_type, info, unk) => + sendResponse(SquadWaypointEvent.Add(squad_supplement_id, char_id, waypoint_type, WaypointEvent(info.zone_number, info.pos, unk))) + } + StopBundlingPackets() + + case SquadResponse.WaypointEvent(WaypointEventAction.Add, char_id, waypoint_type, _, Some(info), unk) => + sendResponse(SquadWaypointEvent.Add(squad_supplement_id, char_id, waypoint_type, WaypointEvent(info.zone_number, info.pos, unk))) + + case SquadResponse.WaypointEvent(WaypointEventAction.Remove, char_id, waypoint_type, _, _, _) => + sendResponse(SquadWaypointEvent.Remove(squad_supplement_id, char_id, waypoint_type)) + + case _ => ; + } + } + case Deployment.CanDeploy(obj, state) => val vehicle_guid = obj.GUID //TODO remove this arbitrary allowance angle when no longer helpful @@ -484,7 +735,7 @@ class WorldSessionActor extends Actor with MDCContextAware { player.Stamina = stamina player.Armor = armor } - sendResponse(CharacterInfoMessage(15, PlanetSideZoneID(10000), 41605313, player.GUID, false, 6404428)) + sendResponse(CharacterInfoMessage(15, PlanetSideZoneID(10000), avatar.CharId, player.GUID, false, 6404428)) RemoveCharacterSelectScreenGUID(player) sendResponse(CharacterInfoMessage(0, PlanetSideZoneID(1), 0, PlanetSideGUID(0), true, 0)) sendResponse(CharacterInfoMessage(0, PlanetSideZoneID(1), 0, PlanetSideGUID(0), true, 0)) @@ -802,7 +1053,7 @@ class WorldSessionActor extends Actor with MDCContextAware { traveler = new Traveler(self, continent.Id) //PropertyOverrideMessage sendResponse(PlanetsideAttributeMessage(PlanetSideGUID(0), 112, 0)) // disable festive backpacks - sendResponse(ReplicationStreamMessage(5, Some(6), Vector(SquadListing()))) //clear squad list + sendResponse(ReplicationStreamMessage(5, Some(6), Vector.empty)) //clear squad list sendResponse(FriendsResponse(FriendAction.InitializeFriendList, 0, true, true, Nil)) sendResponse(FriendsResponse(FriendAction.InitializeIgnoreList, 0, true, true, Nil)) avatarService ! Service.Join(avatar.name) //channel will be player.Name @@ -810,6 +1061,8 @@ class WorldSessionActor extends Actor with MDCContextAware { vehicleService ! Service.Join(avatar.name) //channel will be player.Name galaxyService ! Service.Join("galaxy") //for galaxy-wide messages galaxyService ! Service.Join(s"${avatar.faction}") //for hotspots + squadService ! Service.Join(s"${avatar.faction}") //channel will be player.Faction + squadService ! Service.Join(s"${avatar.CharId}") //channel will be player.CharId (in order to work with packets) cluster ! InterstellarCluster.GetWorld("home3") case InterstellarCluster.GiveWorld(zoneId, zone) => @@ -2828,7 +3081,7 @@ class WorldSessionActor extends Actor with MDCContextAware { sendResponse(CreateShortcutMessage(guid, 1, 0, true, Shortcut.MEDKIT)) sendResponse(ChangeShortcutBankMessage(guid, 0)) //Favorites lists - val (inf, veh) = avatar.Loadouts.partition { case (index, _) => index < 10 } + val (inf, veh) = avatar.EquipmentLoadouts.Loadouts.partition { case (index, _) => index < 10 } inf.foreach { case (index, loadout : InfantryLoadout) => sendResponse(FavoritesMessage(LoadoutType.Infantry, guid, index, loadout.label, InfantryLoadout.DetermineSubtypeB(loadout.exosuit, loadout.subtype))) @@ -2840,9 +3093,14 @@ class WorldSessionActor extends Actor with MDCContextAware { sendResponse(SetChatFilterMessage(ChatChannel.Local, false, ChatChannel.values.toList)) //TODO will not always be "on" like this deadState = DeadState.Alive sendResponse(AvatarDeadStateMessage(DeadState.Alive, 0, 0, tplayer.Position, player.Faction, true)) - sendResponse(PlanetsideAttributeMessage(guid, 53, 1)) + //looking for squad (members) + if(tplayer.LFS || lfsm) { + sendResponse(PlanetsideAttributeMessage(guid, 53, 1)) + avatarService ! AvatarServiceMessage(continent.Id, AvatarAction.PlanetsideAttribute(guid, 53, 1)) + } sendResponse(AvatarSearchCriteriaMessage(guid, List(0, 0, 0, 0, 0, 0))) (1 to 73).foreach(i => { + // not all GUID's are set, and not all of the set ones will always be zero; what does this section do? sendResponse(PlanetsideAttributeMessage(PlanetSideGUID(i), 67, 0)) }) (0 to 30).foreach(i => { @@ -2851,7 +3109,9 @@ class WorldSessionActor extends Actor with MDCContextAware { }) //AvatarAwardMessage //DisplayAwardMessage - //SquadDefinitionActionMessage and SquadDetailDefinitionUpdateMessage + sendResponse(PlanetsideStringAttributeMessage(guid, 0, "Outfit Name")) + //squad stuff (loadouts, assignment) + squadSetup() //MapObjectStateBlockMessage and ObjectCreateMessage? //TacticsMessage? //change the owner on our deployables (re-draw the icons for our deployables too) @@ -2888,6 +3148,97 @@ class WorldSessionActor extends Actor with MDCContextAware { } } + /** + * These messages are dispatched when first starting up the client and connecting to the server for the first time. + * While many of thee messages will be reused for other situations, they appear in this order only during startup. + */ + def FirstTimeSquadSetup() : Unit = { + sendResponse(SquadDetailDefinitionUpdateMessage.Init) + sendResponse(ReplicationStreamMessage(5, Some(6), Vector.empty)) //clear squad list + sendResponse(SquadDefinitionActionMessage(PlanetSideGUID(0), 0, SquadAction.Unknown(6))) + //only need to load these once - they persist between zone transfers and respawns + avatar.SquadLoadouts.Loadouts.foreach { + case (index, loadout : SquadLoadout) => + sendResponse(SquadDefinitionActionMessage(PlanetSideGUID(0), index, SquadAction.ListSquadFavorite(loadout.task))) + } + //non-squad GUID-0 counts as the settings when not joined with a squad + sendResponse(SquadDefinitionActionMessage(PlanetSideGUID(0), 0, SquadAction.AssociateWithSquad())) + sendResponse(SquadDefinitionActionMessage(PlanetSideGUID(0), 0, SquadAction.SetListSquad())) + sendResponse(SquadDefinitionActionMessage(PlanetSideGUID(0), 0, SquadAction.Unknown(18))) + squadService ! SquadServiceMessage(player, continent, SquadServiceAction.InitSquadList()) + squadService ! SquadServiceMessage(player, continent, SquadServiceAction.InitCharId()) + squadSetup = RespawnSquadSetup + } + + /** + * These messages are used during each subsequent respawn to reset the squad colors on player nameplates and marquees. + * By using `squadUI` to maintain relevant information about squad members, + * especially the unique character identifier number, + * only the zone-specific squad members will receive the important messages about their squad member's spawn. + */ + def RespawnSquadSetup() : Unit = { + if(squadUI.nonEmpty) { + sendResponse(PlanetsideAttributeMessage(player.GUID, 31, squad_supplement_id)) + avatarService ! AvatarServiceMessage(s"${continent.Id}/${player.Faction}", AvatarAction.PlanetsideAttribute(player.GUID, 31, squad_supplement_id)) + sendResponse(PlanetsideAttributeMessage(player.GUID, 32, squadUI(player.CharId).index)) + } + } + + /** + * These messages are used during each subsequent respawn to reset the squad colors on player nameplates and marquees. + * During a zone change, + * on top of other squad mates in the zone needing to have their knowledge of this player's squad colors changed, + * the player must also set squad colors for each other squad members. + * Default respawn functionality may resume afterwards. + */ + def ZoneChangeSquadSetup() : Unit = { + RespawnSquadSetup() + GiveSquadColorsInZone() + squadSetup = RespawnSquadSetup + } + + /** + * Allocate all squad members in zone and give their nameplates and their marquees the appropriate squad color. + */ + def GiveSquadColorsInZone() : Unit = { + GiveSquadColorsInZone(squadUI.keys, squad_supplement_id) + } + + /** + * Allocate the listed squad members in zone and give their nameplates and their marquees the appropriate squad color. + * @param members members of the squad to target + */ + def GiveSquadColorsInZone(members : Iterable[Long]) : Unit = { + GiveSquadColorsInZone(members, squad_supplement_id) + } + + /** + * Allocate the listed squad members in zone and give their nameplates and their marquees the appropriate squad color. + * @see `PlanetsideAttributeMessage` + * @param members members of the squad to target + * @param value the assignment value + */ + def GiveSquadColorsInZone(members : Iterable[Long], value : Long) : Unit = { + SquadMembersInZone(members).foreach { + members => sendResponse(PlanetsideAttributeMessage(members.GUID, 31, value)) + } + } + + /** + * For the listed squad member unique character identifier numbers, + * find and return all squad members in the current zone. + * @param members members of the squad to target + * @return a list of `Player` objects + */ + def SquadMembersInZone(members : Iterable[Long]) : Iterable[Player] = { + val players = continent.LivePlayers + for { + charId <- members + player = players.find { _.CharId == charId } + if player.nonEmpty + } yield player.get + } + def handleControlPkt(pkt : PlanetSideControlPacket) = { pkt match { case sync @ ControlSync(diff, _, _, _, _, _, fa, fb) => @@ -2908,10 +3259,14 @@ class WorldSessionActor extends Actor with MDCContextAware { val clientVersion = s"Client Version: $majorVersion.$minorVersion.$revision, $buildDate" log.info(s"New world login to $server with Token:$token. $clientVersion") //TODO begin temp player character auto-loading; remove later + //this is all just temporary character creation used in the dev branch, making explicit values that allow for testing + //the unique character identifier number for this testing character is based on the original test character, + //whose identifier number was 41605314 + //all head features, faction, and sex also match that test character import net.psforever.objects.GlobalDefinitions._ import net.psforever.types.CertificationType._ val faction = PlanetSideEmpire.VS - val avatar = Avatar(s"TestCharacter$sessionId", faction, CharacterGender.Female, 41, CharacterVoice.Voice1) + val avatar = new Avatar(41605313L+sessionId, s"TestCharacter$sessionId", faction, CharacterGender.Female, 41, CharacterVoice.Voice1) avatar.Certifications += StandardAssault avatar.Certifications += MediumAssault avatar.Certifications += StandardExoSuit @@ -2946,6 +3301,7 @@ class WorldSessionActor extends Actor with MDCContextAware { avatar.Certifications += AssaultEngineering avatar.Certifications += Hacking avatar.Certifications += AdvancedHacking + avatar.CEP = 6000001 this.avatar = avatar InitializeDeployableQuantities(avatar) //set deployables ui elements @@ -3043,7 +3399,7 @@ class WorldSessionActor extends Actor with MDCContextAware { sendResponse(TimeOfDayMessage(1191182336)) //custom sendResponse(ContinentalLockUpdateMessage(13, PlanetSideEmpire.VS)) // "The VS have captured the VS Sanctuary." - sendResponse(ReplicationStreamMessage(5, Some(6), Vector(SquadListing()))) //clear squad list + sendResponse(ReplicationStreamMessage(5, Some(6), Vector.empty)) //clear squad list sendResponse(PlanetsideAttributeMessage(PlanetSideGUID(0), 112, 0)) // disable festive backpacks //(0 to 255).foreach(i => { sendResponse(SetEmpireMessage(PlanetSideGUID(i), PlanetSideEmpire.VS)) }) @@ -3339,6 +3695,7 @@ class WorldSessionActor extends Actor with MDCContextAware { case None => false } avatarService ! AvatarServiceMessage(continent.Id, AvatarAction.PlayerState(avatar_guid, msg, spectator, wepInHand)) + updateSquad() } case msg @ ChildObjectStateMessage(object_guid, pitch, yaw) => @@ -3380,6 +3737,7 @@ class WorldSessionActor extends Actor with MDCContextAware { obj.Cloaked = obj.Definition.CanCloak && is_cloaked vehicleService ! VehicleServiceMessage(continent.Id, VehicleAction.VehicleState(player.GUID, vehicle_guid, unk1, pos, ang, vel, flight, unk6, unk7, wheels, unk9, is_cloaked)) } + updateSquad() case (None, _) => //log.error(s"VehicleState: no vehicle $vehicle_guid found in zone") //TODO placing a "not driving" warning here may trigger as we are disembarking the vehicle @@ -4466,7 +4824,7 @@ class WorldSessionActor extends Actor with MDCContextAware { log.info(s"GenericObject: $player is MAX with an unexpected weapon - ${definition.Name}") } } - else if(action == 16) { + else if(action == 16) { //max deployment log.info(s"GenericObject: $player has released the anchors") player.UsingSpecial = SpecialExoSuitDefinition.Mode.Normal avatarService ! AvatarServiceMessage(continent.Id, AvatarAction.PlanetsideAttribute(player.GUID, 19, 0)) @@ -4484,6 +4842,30 @@ class WorldSessionActor extends Actor with MDCContextAware { log.info(s"GenericObject: $player is MAX with an unexpected weapon - ${definition.Name}") } } + else if(action == 36) { //Looking For Squad ON + if(squadUI.nonEmpty) { + if(!lfsm && squadUI(player.CharId).index == 0) { + lfsm = true + avatarService ! AvatarServiceMessage(s"${continent.Id}/${player.Faction}", AvatarAction.PlanetsideAttribute(player.GUID, 53, 1)) + } + } + else if(!avatar.LFS) { + avatar.LFS = true + avatarService ! AvatarServiceMessage(s"${continent.Id}/${player.Faction}", AvatarAction.PlanetsideAttribute(player.GUID, 53, 1)) + } + } + else if(action == 37) { //Looking For Squad OFF + if(squadUI.nonEmpty) { + if(lfsm && squadUI(player.CharId).index == 0) { + lfsm = false + avatarService ! AvatarServiceMessage(s"${continent.Id}/${player.Faction}", AvatarAction.PlanetsideAttribute(player.GUID, 53, 0)) + } + } + else if(avatar.LFS) { + avatar.LFS = false + avatarService ! AvatarServiceMessage(s"${continent.Id}/${player.Faction}", AvatarAction.PlanetsideAttribute(player.GUID, 53, 0)) + } + } case msg @ ItemTransactionMessage(terminal_guid, transaction_type, _, _, _, _) => log.info("ItemTransaction: " + msg) @@ -4522,18 +4904,18 @@ class WorldSessionActor extends Actor with MDCContextAware { None }) match { case Some(owner : Player) => //InfantryLoadout - avatar.SaveLoadout(owner, name, lineno) + avatar.EquipmentLoadouts.SaveLoadout(owner, name, lineno) import InfantryLoadout._ sendResponse(FavoritesMessage(list, player_guid, line, name, DetermineSubtypeB(player.ExoSuit, DetermineSubtype(player)))) case Some(owner : Vehicle) => //VehicleLoadout - avatar.SaveLoadout(owner, name, lineno) + avatar.EquipmentLoadouts.SaveLoadout(owner, name, lineno) sendResponse(FavoritesMessage(list, player_guid, line, name)) case Some(_) | None => log.error("FavoritesRequest: unexpected owner for favorites") } case FavoritesAction.Delete => - avatar.DeleteLoadout(lineno) + avatar.EquipmentLoadouts.DeleteLoadout(lineno) sendResponse(FavoritesMessage(list, player_guid, line, "")) case FavoritesAction.Unknown => @@ -4778,8 +5160,17 @@ class WorldSessionActor extends Actor with MDCContextAware { case msg @ AvatarGrenadeStateMessage(player_guid, state) => log.info("AvatarGrenadeStateMessage: " + msg) - case msg @ SquadDefinitionActionMessage(a, b, c, d, e, f, g, h, i) => - log.info("SquadDefinitionAction: " + msg) + case msg @ SquadDefinitionActionMessage(u1, u2, action) => + log.info(s"SquadDefinitionAction: $msg") + squadService ! SquadServiceMessage(player, continent, SquadServiceAction.Definition(u1, u2, action)) + + case msg @ SquadMembershipRequest(request_type, unk2, unk3, player_name, unk5) => + log.info(s"$msg") + squadService ! SquadServiceMessage(player, continent, SquadServiceAction.Membership(request_type, unk2, unk3, player_name, unk5)) + + case msg @ SquadWaypointRequest(request, _, wtype, unk, info) => + log.info(s"Waypoint Request: $msg") + squadService ! SquadServiceMessage(player, continent, SquadServiceAction.Waypoint(request, wtype, unk, info)) case msg @ GenericCollisionMsg(u1, p, t, php, thp, pv, tv, ppos, tpos, u2, u3, u4) => log.info("Ouch! " + msg) @@ -7520,9 +7911,7 @@ class WorldSessionActor extends Actor with MDCContextAware { /** * Properly format a `DestroyDisplayMessage` packet * given sufficient information about a target (victim) and an actor (killer). - * For the packet, the `*_charId` field is most important to determining distinction between players. - * The "char id" is not a currently supported field for different players so a name hash is used instead. - * The virtually negligent chance of a name hash collision is covered. + * For the packet, the `charId` field is important for determining distinction between players. * @param killer the killer's entry * @param victim the victim's entry * @param method the manner of death @@ -7531,13 +7920,6 @@ class WorldSessionActor extends Actor with MDCContextAware { * @return a `DestroyDisplayMessage` packet that is properly formatted */ def DestroyDisplayMessage(killer : SourceEntry, victim : SourceEntry, method : Int, unk : Int = 121) : DestroyDisplayMessage = { - //TODO charId should reflect the player more properly - val killerCharId = math.abs(killer.Name.hashCode) - var victimCharId = math.abs(victim.Name.hashCode) - if(killerCharId == victimCharId && !killer.Name.equals(victim.Name)) { - //odds of hash collision in a populated zone should be close to odds of being struck by lightning - victimCharId = Int.MaxValue - victimCharId + 1 - } val killer_seated = killer match { case obj : PlayerSource => obj.Seated case _ => false @@ -7547,9 +7929,9 @@ class WorldSessionActor extends Actor with MDCContextAware { case _ => false } new DestroyDisplayMessage( - killer.Name, killerCharId, killer.Faction, killer_seated, + killer.Name, killer.CharId, killer.Faction, killer_seated, unk, method, - victim.Name, victimCharId, victim.Faction, victim_seated + victim.Name, victim.CharId, victim.Faction, victim_seated ) } @@ -8478,6 +8860,7 @@ class WorldSessionActor extends Actor with MDCContextAware { }) DisownDeployables() drawDeloyableIcon = RedrawDeployableIcons //important for when SetCurrentAvatar initializes the UI next zone + squadSetup = ZoneChangeSquadSetup continent.Population ! Zone.Population.Leave(avatar) } @@ -8947,6 +9330,76 @@ class WorldSessionActor extends Actor with MDCContextAware { } } + def SwapSquadUIElements(squad : Squad, fromIndex : Int, toIndex : Int) : Unit = { + if(squadUI.nonEmpty) { + val fromMember = squad.Membership(toIndex) //the players have already been swapped in the backend object + val fromCharId = fromMember.CharId + val toMember = squad.Membership(fromIndex) //the players have already been swapped in the backend object + val toCharId = toMember.CharId + val id = 11 + if(toCharId > 0) { + //toMember and fromMember have swapped places + val fromElem = squadUI(fromCharId) + val toElem = squadUI(toCharId) + squadUI(toCharId) = SquadUIElement(fromElem.name, toIndex, fromElem.zone, fromElem.health, fromElem.armor, fromElem.position) + squadUI(fromCharId) = SquadUIElement(toElem.name, fromIndex, toElem.zone, toElem.health, toElem.armor, toElem.position) + sendResponse(SquadMemberEvent.Add(id, toCharId, toIndex, fromElem.name, fromElem.zone, unk7 = 0)) + sendResponse(SquadMemberEvent.Add(id, fromCharId, fromIndex, toElem.name, toElem.zone, unk7 = 0)) + sendResponse( + SquadState( + PlanetSideGUID(id), + List( + SquadStateInfo(fromCharId, toElem.health, toElem.armor, toElem.position, 2, 2, false, 429, None, None), + SquadStateInfo(toCharId, fromElem.health, fromElem.armor, fromElem.position, 2, 2, false, 429, None, None) + ) + ) + ) + } + else { + //previous fromMember has moved toMember + val elem = squadUI(fromCharId) + squadUI(fromCharId) = SquadUIElement(elem.name, toIndex, elem.zone, elem.health, elem.armor, elem.position) + sendResponse(SquadMemberEvent.Remove(id, fromCharId, fromIndex)) + sendResponse(SquadMemberEvent.Add(id, fromCharId, toIndex, elem.name, elem.zone, unk7 = 0)) + sendResponse( + SquadState( + PlanetSideGUID(id), + List(SquadStateInfo(fromCharId, elem.health, elem.armor, elem.position, 2, 2, false, 429, None, None)) + ) + ) + } + val charId = avatar.CharId + if(toCharId == charId) { + sendResponse(PlanetsideAttributeMessage(player.GUID, 32, toIndex)) + } + else if(fromCharId == charId) { + sendResponse(PlanetsideAttributeMessage(player.GUID, 32, fromIndex)) + } + } + } + + def NoSquadUpdates() : Unit = { } + + def SquadUpdates() : Unit = { + squadService ! SquadServiceMessage( + player, + continent, + continent.GUID(player.VehicleSeated) match { + case Some(vehicle : Vehicle) => + SquadServiceAction.Update(player.CharId, vehicle.Health, vehicle.MaxHealth, vehicle.Shields, vehicle.MaxShields, vehicle.Position, continent.Number) + case Some(obj : PlanetSideGameObject with WeaponTurret) => + SquadServiceAction.Update(player.CharId, obj.Health, obj.MaxHealth, 0, 0, obj.Position, continent.Number) + case _ => + SquadServiceAction.Update(player.CharId, player.Health, player.MaxHealth, player.Armor, player.MaxArmor, player.Position, continent.Number) + } + ) + } + + def PeriodicUpdatesWhenEnrolledInSquad() : Unit = { + queuedSquadActions(squadUpdateCounter)() + squadUpdateCounter = (squadUpdateCounter + 1) % queuedSquadActions.length + } + def failWithError(error : String) = { log.error(error) sendResponse(ConnectionClose()) @@ -9103,6 +9556,8 @@ object WorldSessionActor { completeAction : () => Unit, tickAction : Option[() => Unit] = None) + protected final case class SquadUIElement(name : String, index : Int, zone : Int, health : Int, armor : Int, position : Vector3) + private final case class NtuCharging(tplayer: Player, vehicle: Vehicle) private final case class NtuDischarging(tplayer: Player, vehicle: Vehicle, silo_guid: PlanetSideGUID) diff --git a/pslogin/src/test/scala/PacketCodingActorTest.scala b/pslogin/src/test/scala/PacketCodingActorTest.scala index 5efe312b9..e055d325b 100644 --- a/pslogin/src/test/scala/PacketCodingActorTest.scala +++ b/pslogin/src/test/scala/PacketCodingActorTest.scala @@ -485,7 +485,7 @@ class PacketCodingActorITest extends ActorTest { ) val obj = DetailedPlayerData(pos, app, char, InventoryData(Nil), DrawnSlot.None) val pkt = MultiPacketBundle(List(ObjectCreateDetailedMessage(0x79, PlanetSideGUID(75), obj))) - val string_hex = hex"000900001879060000bc84b000000000000000000002040000097049006c006c006c004900490049006c006c006c0049006c0049006c006c0049006c006c006c0049006c006c00490084524000000000000000000000000000000020000007f35703fffffffffffffffffffffffffffffffc000000000000000000000000000000000000000190019000640000000000c800c80000000000000000000000000000000000000001c00042c54686c7000000000000000000000000000000000000000000000000000000000000000000000400e0" + val string_hex = hex"00090000186c060000bc84b000000000000000000002040000097049006c006c006c004900490049006c006c006c0049006c0049006c006c0049006c006c006c0049006c006c00490084524000000000000000000000000000000020000007f35703fffffffffffffffffffffffffffffffc000000000000000000000000000000000000000190019000640000000000c800c80000000000000000000000000000000000000001c00042c54686c7000000000000000000000000000000000000000000000000000000000000100000000400e0" "PacketCodingActor" should { "bundle an r-originating packet into an l-facing SlottedMetaPacket byte stream data (SlottedMetaPacket)" in {