From 24691ec239b3b1a93e8a120e84dfd404f4914071 Mon Sep 17 00:00:00 2001 From: FateJH Date: Tue, 30 Jul 2019 11:30:57 -0400 Subject: [PATCH] initial work on SquadInvitationRequestMessage packet, originally by aphedox; initial work on packet and tests for CharacterKnowledgeMessage; re-organization of SquadService workflow; prototyping for SquadSwitchboard --- .../psforever/packet/GamePacketOpcode.scala | 4 +- .../game/CharacterKnowledgeMessage.scala | 57 ++ .../game/SquadInvitationRequestMessage.scala | 41 + .../services/teamwork/SquadResponse.scala | 4 +- .../services/teamwork/SquadService.scala | 732 +++++++++--------- .../services/teamwork/SquadSwitchboard.scala | 142 ++++ .../game/CharacterKnowledgeMessageTest.scala | 70 ++ .../src/main/scala/WorldSessionActor.scala | 108 ++- 8 files changed, 765 insertions(+), 393 deletions(-) create mode 100644 common/src/main/scala/net/psforever/packet/game/CharacterKnowledgeMessage.scala create mode 100644 common/src/main/scala/net/psforever/packet/game/SquadInvitationRequestMessage.scala create mode 100644 common/src/main/scala/services/teamwork/SquadSwitchboard.scala create mode 100644 common/src/test/scala/game/CharacterKnowledgeMessageTest.scala diff --git a/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala b/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala index 03ed5e257..54452c557 100644 --- a/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala +++ b/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala @@ -595,8 +595,8 @@ object GamePacketOpcode extends Enumeration { 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/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/services/teamwork/SquadResponse.scala b/common/src/main/scala/services/teamwork/SquadResponse.scala index b74f153fa..26215f029 100644 --- a/common/src/main/scala/services/teamwork/SquadResponse.scala +++ b/common/src/main/scala/services/teamwork/SquadResponse.scala @@ -21,7 +21,7 @@ object SquadResponse { final case class Join(squad : Squad, positionsToUpdate : List[Int]) 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 SwapMember(squad : Squad, from_index : Int, to_index : Int) extends Response + final case class AssignMember(squad : Squad, from_index : Int, to_index : Int) extends Response - final case class Detail(guid : PlanetSideGUID, leader : String, task : String, zone : PlanetSideZoneID, member_info : List[SquadPositionDetail]) extends Response + final case class Detail(guid : PlanetSideGUID, squad_detail : SquadDetail) extends Response } diff --git a/common/src/main/scala/services/teamwork/SquadService.scala b/common/src/main/scala/services/teamwork/SquadService.scala index 0f8ae794a..e3dbe5922 100644 --- a/common/src/main/scala/services/teamwork/SquadService.scala +++ b/common/src/main/scala/services/teamwork/SquadService.scala @@ -1,7 +1,7 @@ // Copyright (c) 2019 PSForever package services.teamwork -import akka.actor.Actor +import akka.actor.{Actor, ActorRef, Props} import net.psforever.objects.Player import net.psforever.objects.definition.converter.StatConverter import net.psforever.objects.loadouts.SquadLoadout @@ -21,6 +21,7 @@ class SquadService extends Actor { private var memberToSquad : mutable.LongMap[Squad] = mutable.LongMap[Squad]() private var idToSquad : TrieMap[PlanetSideGUID, Squad] = new TrieMap[PlanetSideGUID, Squad]() + private var idToSwitchboard : TrieMap[PlanetSideGUID, ActorRef] = new TrieMap[PlanetSideGUID, ActorRef]() private var i : Int = 1 private val publishedLists : TrieMap[PlanetSideEmpire.Value, ListBuffer[SquadInfo]] = TrieMap[PlanetSideEmpire.Value, ListBuffer[SquadInfo]]( PlanetSideEmpire.TR -> ListBuffer.empty, @@ -28,6 +29,7 @@ class SquadService extends Actor { PlanetSideEmpire.VS -> ListBuffer.empty ) private val bids : mutable.LongMap[PositionBid] = mutable.LongMap[PositionBid]() + private val viewDetails : mutable.LongMap[PlanetSideGUID] = mutable.LongMap[PlanetSideGUID]() private [this] val log = org.log4s.getLogger @@ -82,51 +84,55 @@ class SquadService extends Actor { PlanetSideGUID(out) } - def GetParticipatingSquad(player : Player, zone : Int) : Option[Squad] = { + def GetParticipatingSquad(player : Player) : Option[Squad] = { memberToSquad.get(player.CharId) match { - case opt @ Some(squad) => - squad.Membership.find(_.Name == player.Name).get.ZoneId = zone + case opt @ Some(_) => opt case None => None } } - def GetLeadingSquad(player : Player, zone : Int, opt : Option[Squad]) : Squad = { + def GetLeadingSquad(player : Player, zone : Int, opt : Option[Squad]) : Option[Squad] = { val charId = player.CharId - val squadOut = opt match { + opt match { case Some(squad) => if(squad.Leader.CharId == charId) { - squad + Some(squad) } else { - GetLeadingSquad(player, zone, None) + None } case None => memberToSquad.get(charId) match { - case Some(squad) if squad.Leader.CharId.equals(charId) => - squad + case Some(squad) if squad.Leader.CharId == charId => + Some(squad) case _ => - val faction = player.Faction - val id = GetNextSquadId() - val squad = new Squad(id, faction) - val leadPosition = squad.Membership(squad.LeaderPositionIndex) - val name = player.Name - leadPosition.Name = name - leadPosition.CharId = charId - leadPosition.Health = player.Health - leadPosition.Armor = player.Armor - leadPosition.Position = player.Position - leadPosition.ZoneId = zone - log.info(s"$name-$faction has started a new squad") - memberToSquad += charId -> squad - idToSquad += id -> squad - squad + None } } - squadOut.Membership(squadOut.LeaderPositionIndex).ZoneId = zone - squadOut + } + + def StartSquad(player : Player) : Squad = { + val charId = player.CharId + val faction = player.Faction + val id = GetNextSquadId() + val name = player.Name + val squad = new Squad(id, faction) + val leadPosition = squad.Membership(squad.LeaderPositionIndex) + leadPosition.Name = name + leadPosition.CharId = charId + leadPosition.Health = player.Health + leadPosition.Armor = player.Armor + leadPosition.Position = player.Position + leadPosition.ZoneId = 1 + val switchboard = context.actorOf(Props[SquadSwitchboard], s"squad${id.guid}") + memberToSquad += charId -> squad + idToSquad += id -> squad + idToSwitchboard += id -> switchboard + log.info(s"$name-$faction has started a new squad") + squad } val SquadEvents = new GenericEventBus[SquadServiceResponse] @@ -134,16 +140,16 @@ class SquadService extends Actor { 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 path = s"/$faction/Squad" val who = sender() log.info(s"$who has joined $path") SquadEvents.subscribe(who, path) //send initial squad catalog - sender ! SquadServiceResponse(s"$faction/Squad", SquadResponse.InitList(publishedLists(PlanetSideEmpire(faction)).toVector)) + sender ! SquadServiceResponse(s"/$faction/Squad", SquadResponse.InitList(publishedLists(PlanetSideEmpire(faction)).toVector)) //subscribe to the player's personal channel - necessary only to inform about any previous squad association case Service.Join(char_id) => - val path = s"$char_id/Squad" + val path = s"/$char_id/Squad" val who = sender() log.info(s"$who has joined $path") SquadEvents.subscribe(who, path) //TODO squad-specific switchboard @@ -195,101 +201,80 @@ class SquadService extends Actor { case SquadServiceMessage(tplayer, squad_action) => squad_action match { case SquadAction.Membership(request_type, char_id, optional_char_id, _, _) => request_type match { case SquadRequestType.Invite => + //char_id is the inviter, e.g., the (prospective) squad leader + //this is just busy work; for actual joining operations, see SquadRequestType.Accept (optional_char_id, memberToSquad.get(char_id)) match { - case (Some(toCharId), Some(squad)) => - bids(toCharId) = VacancyBid(char_id, squad.GUID) - SquadEvents.publish( SquadServiceResponse(s"$toCharId/Squad", SquadResponse.Invite(char_id, toCharId, tplayer.Name)) ) - case (Some(toCharId), None) => - val ourSquad = GetLeadingSquad(tplayer, 1, None) - memberToSquad.remove(char_id) - bids(toCharId) = CamraderieBid(char_id, ourSquad) - SquadEvents.publish( SquadServiceResponse(s"$toCharId/Squad", SquadResponse.Invite(char_id, toCharId, tplayer.Name)) ) + case (Some(invitee), Some(squad)) => + bids(invitee) = VacancyBid(char_id, squad.GUID) + log.info(s"$invitee has been invited to squad ${squad.Task} by $char_id") + SquadEvents.publish( SquadServiceResponse(s"/$invitee/Squad", SquadResponse.Membership(SquadResponseType.Invite, 0, 0, char_id, Some(invitee), tplayer.Name, false, Some(None))) ) + SquadEvents.publish( SquadServiceResponse(s"/$char_id/Squad", SquadResponse.Membership(SquadResponseType.Invite, 0, 0, invitee, Some(char_id), tplayer.Name, true, Some(None))) ) + case (Some(invitee), None) => + //the inviter does not currently belong to a squad; check for an existing placeholder, or create a new one + val ourSquad = { + bids.find { case (inviter, _) => inviter == char_id } match { + case Some((_, SpontaneousBid(_, _squad))) => + _squad //borrow + case _ => + val _squad = StartSquad(tplayer) + memberToSquad.remove(char_id) //completely unlist until assured the squad is necessary + _squad + } + } + bids(invitee) = SpontaneousBid(char_id, ourSquad) + SquadEvents.publish( SquadServiceResponse(s"/$invitee/Squad", SquadResponse.Membership(SquadResponseType.Invite, 0, 0, char_id, Some(invitee), tplayer.Name, false, Some(None))) ) + SquadEvents.publish( SquadServiceResponse(s"/$char_id/Squad", SquadResponse.Membership(SquadResponseType.Invite, 0, 0, invitee, Some(char_id), tplayer.Name, true, Some(None))) ) case _ => ; } - case SquadRequestType.Leave => - val squad = memberToSquad(char_id) - if(optional_char_id.contains(char_id)) { - //leaving the squad of our own accord - } - else { - val leader = squad.Leader - if(optional_char_id.contains(leader.CharId)) { - //kicked by the squad leader - } - } - val membership = squad.Membership.zipWithIndex - val (member, index) = membership - .find { case (_member, _) => _member.CharId == char_id } - .get - val updateList = membership.collect({ case (_member, _index) if _member.CharId > 0 => (_member.CharId, _index) }).toList - memberToSquad.remove(char_id) - member.Name = "" - member.CharId = 0 - - val size = squad.Size - if(size == 1) { - //squad is rendered to just one person; collapse it - (membership.collect({ case (_member, _) if _member.CharId > 0 => _member.CharId }) :+ char_id) - .foreach { charId => - SquadEvents.publish( SquadServiceResponse(s"$charId/Squad", SquadResponse.Leave(squad, updateList)) ) - memberToSquad.remove(charId) - } - idToSquad.remove(squad.GUID) - } - else { - //squad continues, despite player's parting - //member leaves the squad completely - sender ! SquadServiceResponse("", SquadResponse.Leave(squad, updateList)) - //other squad members see the member leaving only - val leavingMember = List((char_id, index)) - membership - .collect({ case (_member, _) if _member.CharId > 0 => _member.CharId }) - .foreach { charId => - SquadEvents.publish( SquadServiceResponse(s"$charId/Squad", SquadResponse.Leave(squad, leavingMember)) ) - } - } - case SquadRequestType.Accept => + //char_id is the invitee, e.g., the person joining the squad bids.remove(char_id) match { - case Some(NormalBid(inviterCharId, squadGUID, line)) if idToSquad.get(squadGUID).nonEmpty => + case Some(NormalBid(_/*inviterCharId*/, squadGUID, line)) if idToSquad.get(squadGUID).nonEmpty => + //player requested to join a squad's specific position JoinSquad(tplayer, idToSquad(squadGUID), line) case Some(VacancyBid(inviterCharId, squadGUID)) if idToSquad.get(squadGUID).nonEmpty => + //we were invited by the squad leader into an existing squad val squad = idToSquad(squadGUID) squad.Membership.zipWithIndex.find({ case (member, index) => - ValidOpenSquadPosition(squad, index, squad.Membership(index), tplayer.Certifications) + ValidOpenSquadPosition(squad, index, member, tplayer.Certifications) }) match { case Some((_, line)) => + SquadEvents.publish( SquadServiceResponse(s"/$inviterCharId/Squad", SquadResponse.Membership(SquadResponseType.Accept, 0, 0, inviterCharId, Some(char_id), tplayer.Name, false, Some(None))) ) + SquadEvents.publish( SquadServiceResponse(s"/$char_id/Squad", SquadResponse.Membership(SquadResponseType.Accept, 0, 0, char_id, Some(inviterCharId), "", true, Some(None))) ) JoinSquad(tplayer, squad, line) case _ => ; } - case Some(CamraderieBid(inviterCharId, placeholderSquad)) => - (GetParticipatingSquad(tplayer, 1) match { + case Some(SpontaneousBid(inviterCharId, placeholderSquad)) => + //we were invited by someone into a new squad they would form + (GetParticipatingSquad(tplayer) match { case Some(participating) => if(participating.Leader.CharId == inviterCharId) { Some(participating) } else { - //inviter is not leader of squad; bounce this request off of the squad leader + //inviter joined a squad and is not its leader; bounce this request off of the squad leader + //TODO squad leader receives " wants to join squad" prompt val leaderCharId = participating.Leader.CharId bids(char_id) = VacancyBid(leaderCharId, participating.GUID) //reframed request - //TODO squad leader receives " wants to join squad" prompt - //SquadEvents.publish(SquadServiceResponse(s"$leaderCharId/Squad", SquadResponse.Invite(char_id, leaderCharId, tplayer.Name))) + SquadEvents.publish(SquadServiceResponse(s"/$leaderCharId/Squad", SquadResponse.Invite(char_id, leaderCharId, tplayer.Name))) None } case None => placeholderSquad.Task = s"${tplayer.Name}'s Squad" memberToSquad(inviterCharId) = placeholderSquad - SquadEvents.publish(SquadServiceResponse(s"$inviterCharId/Squad", SquadResponse.InitSquad(placeholderSquad.GUID))) + SquadEvents.publish(SquadServiceResponse(s"/$inviterCharId/Squad", SquadResponse.InitSquad(placeholderSquad.GUID))) Some(placeholderSquad) }) match { case Some(squad) => squad.Membership.zipWithIndex.find({ case (member, index) => - ValidOpenSquadPosition(squad, index, squad.Membership(index), tplayer.Certifications) + ValidOpenSquadPosition(squad, index, member, tplayer.Certifications) }) match { case Some((_, line)) => + SquadEvents.publish( SquadServiceResponse(s"/$inviterCharId/Squad", SquadResponse.Membership(SquadResponseType.Accept, 0, 0, inviterCharId, Some(char_id), tplayer.Name, false, Some(None))) ) + SquadEvents.publish( SquadServiceResponse(s"/$char_id/Squad", SquadResponse.Membership(SquadResponseType.Accept, 0, 0, char_id, Some(inviterCharId), "", true, Some(None))) ) JoinSquad(tplayer, squad, line) case _ => ; } @@ -299,6 +284,50 @@ class SquadService extends Actor { case _ => ; } + case SquadRequestType.Leave => + //char_id is the player leaving + val squad = memberToSquad(char_id) + val leader = squad.Leader.CharId + if(char_id == leader) { + //squad leader is leaving his own squad, so it will be disbanded + squad.Membership + .filterNot { _.CharId == leader } + .foreach { member => + val charId = member.CharId + SquadEvents.publish(SquadServiceResponse(s"/$charId/Squad", SquadResponse.Membership(SquadResponseType.Disband, 0, 0, charId, None, "", false, Some(None)))) + } + CloseOutSquad(squad) + SquadEvents.publish(SquadServiceResponse(s"/$leader/Squad", SquadResponse.Membership(SquadResponseType.Disband, 0, 0, leader, None, "", true, Some(None)))) + } + else { + if(optional_char_id.contains(char_id)) { + //leaving the squad of our own accord + LeaveSquad(tplayer, squad) + } + else if(optional_char_id.contains(leader)) { + //kicked by the squad leader + SquadEvents.publish( SquadServiceResponse(s"/$char_id/Squad", SquadResponse.Membership(SquadResponseType.Leave, 0, 0, char_id, Some(leader), tplayer.Name, false, Some(None))) ) + SquadEvents.publish( SquadServiceResponse(s"/$leader/Squad", SquadResponse.Membership(SquadResponseType.Leave, 0, 0, leader, Some(char_id), "", true, Some(None))) ) + LeaveSquad(tplayer, squad) + } + } + + case SquadRequestType.Reject => + //a player has opted out of joining a squad + (bids.remove(char_id) match { + case Some(SpontaneousBid(inviterCharId, squad)) if squad.Leader.CharId != char_id => + (inviterCharId, squad) + case Some(VacancyBid(inviterCharId, guid)) if idToSquad(guid).Leader.CharId != char_id => + (inviterCharId, idToSquad.get(guid)) + case _ => ; + (0L, None) + }) match { + case (inviterCharId, Some(squad)) => + SquadEvents.publish( SquadServiceResponse(s"/$inviterCharId/Squad", SquadResponse.Membership(SquadResponseType.Reject, 0, 0, inviterCharId, Some(char_id), tplayer.Name, false, Some(None))) ) + SquadEvents.publish( SquadServiceResponse(s"/$char_id/Squad", SquadResponse.Membership(SquadResponseType.Reject, 0, 0, char_id, Some(inviterCharId), "", true, Some(None))) ) + case _ => ; + } + case _ => ; } @@ -307,10 +336,8 @@ class SquadService extends Actor { case Some(squad) => squad.Membership.find(_.CharId == char_id) match { case Some(member) => - val newHealth = StatConverter.Health(health, max_health, min=1, max=64) - val newArmor = StatConverter.Health(armor, max_armor, min=1, max=64) - member.Health = newHealth - member.Armor = newArmor + 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 sender ! SquadServiceResponse("", SquadResponse.UpdateMembers( @@ -328,36 +355,27 @@ class SquadService extends Actor { case SquadAction.Definition(tplayer : Player, zone_ordinal_number : Int, guid : PlanetSideGUID, line : Int, action : SquadAction) => import net.psforever.packet.game.SquadAction._ - val squadOpt = GetParticipatingSquad(tplayer, zone_ordinal_number) + val pSquadOpt = GetParticipatingSquad(tplayer) + val lSquadOpt = GetLeadingSquad(tplayer, zone_ordinal_number, pSquadOpt) + //the following actions can only be performed by a squad's leader action match { case SaveSquadFavorite() => - squadOpt match { - case Some(squad) if squad.Leader.CharId == tplayer.CharId && squad.Task.nonEmpty && squad.ZoneId > 0 => - tplayer.SquadLoadouts.SaveLoadout(squad, squad.Task, line) - sender ! SquadServiceResponse("", SquadResponse.ListSquadFavorite(line, squad.Task)) - case _ => ; + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) + if(squad.Task.nonEmpty && squad.ZoneId > 0) { + tplayer.SquadLoadouts.SaveLoadout(squad, squad.Task, line) + sender ! SquadServiceResponse("", SquadResponse.ListSquadFavorite(line, squad.Task)) } case LoadSquadFavorite() => - (squadOpt match { - case Some(squad) if squad.Size == 1 => - //we are the leader of our own squad, but no one else has joined yet - Some(squad) - case None => ; - //we are not yet member of a squad; start a squad to support our favorite definition - Some(GetLeadingSquad(tplayer, zone_ordinal_number, None)) - case _ => ; - //player is member of populated squad; should not overwrite squad definition with favorite - None - }, tplayer.SquadLoadouts.LoadLoadout(line)) match { - case (Some(squad : Squad), Some(loadout : SquadLoadout)) => + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) + tplayer.SquadLoadouts.LoadLoadout(line) match { + case Some(loadout : SquadLoadout) if squad.Size == 1 => log.info(s"${tplayer.Name} is loading a squad composition: $loadout") SquadService.LoadSquadDefinition(squad, loadout) sender ! SquadServiceResponse("", SquadResponse.InitSquad(squad.GUID)) UpdateSquadList(squad, SquadInfo().Task(squad.Task).ZoneId(PlanetSideZoneID(squad.ZoneId)).Capacity(squad.Capacity)) UpdateSquadDetail(PlanetSideGUID(0), squad) - - case _ => ; + case _ => } case DeleteSquadFavorite() => @@ -366,23 +384,20 @@ class SquadService extends Actor { case ChangeSquadPurpose(purpose) => log.info(s"${tplayer.Name}-${tplayer.Faction} has changed his squad's task to $purpose") - val squad = GetLeadingSquad(tplayer, zone_ordinal_number, squadOpt) + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) squad.Task = purpose - UpdateSquadListWhenListed(squad, SquadInfo().Task(purpose)) - SquadDetail().Task(purpose) - UpdateListedSquadDetail(squad.GUID, squad) + UpdateSquadDetailWhenListed(squad.GUID, squad, SquadDetail().Task(purpose)) case ChangeSquadZone(zone) => log.info(s"${tplayer.Name}-${tplayer.Faction} has changed squad's ops zone to $zone") - val squad = GetLeadingSquad(tplayer, zone_ordinal_number, squadOpt) + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) squad.ZoneId = zone.zoneId.toInt UpdateSquadListWhenListed(squad, SquadInfo().ZoneId(zone)) - SquadDetail().ZoneId(zone) - UpdateListedSquadDetail(squad.GUID, squad) + UpdateSquadDetailWhenListed(squad.GUID, squad, SquadDetail().ZoneId(zone)) case CloseSquadMemberPosition(position) => - val squad = GetLeadingSquad(tplayer, zone_ordinal_number, squadOpt) + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) squad.Availability.lift(position) match { case Some(true) => squad.Availability.update(position, false) @@ -396,151 +411,93 @@ class SquadService extends Actor { } memberPosition.Close() UpdateSquadListWhenListed(squad, listingChanged) - SquadDetail().Members(List(SquadPositionEntry(position, SquadPositionDetail.Closed))) - UpdateListedSquadDetail(squad.GUID, squad) + UpdateSquadDetailWhenListed(squad.GUID, squad, + SquadDetail().Members(List(SquadPositionEntry(position, SquadPositionDetail.Closed))) + ) case Some(false) | None => ; } case AddSquadMemberPosition(position) => - val squad = GetLeadingSquad(tplayer, zone_ordinal_number, squadOpt) + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) squad.Availability.lift(position) match { case Some(false) => log.info(s"${tplayer.Name}-${tplayer.Faction} has opened the #$position position in squad") squad.Availability.update(position, true) UpdateSquadListWhenListed(squad, SquadInfo().Capacity(squad.Capacity)) - SquadDetail().Members(List(SquadPositionEntry(position, SquadPositionDetail.Open))) - UpdateListedSquadDetail(squad.GUID, squad) + UpdateSquadDetailWhenListed(squad.GUID, squad, + SquadDetail().Members(List(SquadPositionEntry(position, SquadPositionDetail.Open))) + ) case Some(true) | None => ; } case ChangeSquadMemberRequirementsRole(position, role) => - val squad = GetLeadingSquad(tplayer, zone_ordinal_number, squadOpt) + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) squad.Availability.lift(position) match { case Some(true) => log.info(s"${tplayer.Name}-${tplayer.Faction} has changed the role of squad position #$position") squad.Membership(position).Role = role - SquadDetail().Members(List(SquadPositionEntry(position, SquadPositionDetail().Role(role)))) - UpdateListedSquadDetail(squad.GUID, squad) + UpdateSquadDetailWhenListed(squad.GUID, squad, + SquadDetail().Members(List(SquadPositionEntry(position, SquadPositionDetail().Role(role)))) + ) case Some(false) | None => ; } case ChangeSquadMemberRequirementsDetailedOrders(position, orders) => - val squad = GetLeadingSquad(tplayer, zone_ordinal_number, squadOpt) + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) squad.Availability.lift(position) match { case Some(true) => log.info(s"${tplayer.Name}-${tplayer.Faction} has changed the orders for squad position #$position") squad.Membership(position).Orders = orders - SquadDetail().Members(List(SquadPositionEntry(position, SquadPositionDetail().DetailedOrders(orders)))) - UpdateListedSquadDetail(squad.GUID, squad) + UpdateSquadDetailWhenListed(squad.GUID, squad, + SquadDetail().Members(List(SquadPositionEntry(position, SquadPositionDetail().DetailedOrders(orders)))) + ) case Some(false) | None => ; } case ChangeSquadMemberRequirementsCertifications(position, certs) => - val squad = GetLeadingSquad(tplayer, zone_ordinal_number, squadOpt) + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) squad.Availability.lift(position) match { case Some(true) => log.info(s"${tplayer.Name}-${tplayer.Faction} has changed the requirements for squad position #$position") squad.Membership(position).Requirements = certs - SquadDetail().Members(List(SquadPositionEntry(position, SquadPositionDetail().Requirements(certs)))) - UpdateListedSquadDetail(squad.GUID, squad) + UpdateSquadDetailWhenListed(squad.GUID, squad, + SquadDetail().Members(List(SquadPositionEntry(position, SquadPositionDetail().Requirements(certs)))) + ) case Some(false) | None => ; } case LocationFollowsSquadLead(state) => - val squad = GetLeadingSquad(tplayer, zone_ordinal_number, squadOpt) if(state) { log.info(s"${tplayer.Name}-${tplayer.Faction} has moves the rally to the leader's position") } else { log.info(s"${tplayer.Name}-${tplayer.Faction} has let the rally move freely") } + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) squad.LocationFollowsSquadLead = state case AutoApproveInvitationRequests(state) => - val squad = GetLeadingSquad(tplayer, zone_ordinal_number, squadOpt) if(state) { log.info(s"${tplayer.Name}-${tplayer.Faction} is allowing all requests to join the squad") } else { log.info(s"${tplayer.Name}-${tplayer.Faction} has started screening invitation requests") } + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) squad.AutoApproveInvitationRequests = state - case SelectRoleForYourself(position) => - //TODO need to ask permission from the squad leader, unless our character is the squad leader or already currently in the squad - squadOpt match { - case Some(squad) if squad.GUID == guid || squad.Leader.Name == tplayer.Name => - //already a member of this squad, or the leader; swap positions freely - val membership = squad.Membership.zipWithIndex - val toMember = squad.Membership(position) - if(ValidOpenSquadPosition(squad, position, toMember, tplayer.Certifications)) { - //acquire this membership position - membership.find { case (member, _) => member.Name == tplayer.Name } match { - case Some((fromMember, fromIndex)) => - //copy member details - toMember.Name = fromMember.Name - toMember.CharId = fromMember.CharId - toMember.Health = fromMember.Health - toMember.Armor = fromMember.Armor - toMember.Position = fromMember.Position - toMember.ZoneId = fromMember.ZoneId - //old membership no longer valid - fromMember.Name = "" - fromMember.CharId = 0L - if(fromIndex == squad.LeaderPositionIndex) { - squad.LeaderPositionIndex = position - } - membership - .collect({ case (_member, _) if _member.CharId > 0 => _member.Name }) - .foreach { name => - SquadEvents.publish(SquadServiceResponse(s"$name/Squad", SquadResponse.SwapMember(squad, fromIndex, position))) - } - case None => - toMember.Name = tplayer.Name - toMember.CharId = tplayer.CharId - toMember.Health = StatConverter.Health(tplayer.Health, tplayer.MaxHealth, min = 1, max = 64) - toMember.Armor = StatConverter.Health(tplayer.Armor, tplayer.MaxArmor, min = 1, max = 64) - toMember.Position = tplayer.Position - toMember.ZoneId = 13 - memberToSquad(tplayer.CharId) = squad - } - } - UpdateSquadDetail(squad.GUID, squad) - - case Some(_) => - //not a member of the requesting squad; do nothing - case None => - //not a member of any squad; consider request of joining the target squad - idToSquad.get(guid) match { - case Some(squad) => - val member = squad.Membership(position) - if(squad.Availability(position) && member.CharId == 0 && - tplayer.Certifications.intersect(member.Requirements) == member.Requirements) { - bids(tplayer.CharId) = NormalBid(squad.Leader.CharId, guid, position) - val leader = squad.Leader - //TODO need to ask permission from the squad leader, unless auto-approve is in effect - sender ! SquadServiceResponse("", SquadResponse.Membership(SquadResponseType.Invite, 0, 0, leader.CharId, Some(tplayer.CharId), leader.Name, false, None)) - } - - case None => - //squad does not exist!? assume old data - //reload squad list data and blank the squad definition the user is looking at - } - } - case RequestListSquad() => - val squad = GetLeadingSquad(tplayer, zone_ordinal_number, squadOpt) - if(!squad.Listed) { + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) + if(!squad.Listed && squad.Task.nonEmpty && squad.ZoneId > 0) { log.info(s"${tplayer.Name}-${tplayer.Faction} has opened public recruitment for squad ${squad.Task}") squad.Listed = true sender ! SquadServiceResponse("", SquadResponse.InitSquad(squad.GUID)) UpdateSquadList(squad, None) - sender ! SquadServiceResponse("", SquadResponse.Unknown17(squad, tplayer.CharId)) } case StopListSquad() => - val squad = GetLeadingSquad(tplayer, zone_ordinal_number, squadOpt) - if(!squad.Listed) { + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) + if(squad.Listed) { log.info(s"${tplayer.Name}-${tplayer.Faction} has closed public recruitment for squad ${squad.Task}") squad.Listed = false sender ! SquadServiceResponse("", SquadResponse.InitSquad(PlanetSideGUID(0))) @@ -548,41 +505,101 @@ class SquadService extends Actor { } case ResetAll() => - squadOpt match { - case Some(squad) if squad.Leader.CharId == tplayer.CharId => - 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() - }) - squad.LocationFollowsSquadLead = false - squad.AutoApproveInvitationRequests = false - UpdateSquadList(squad, SquadInfo().Task("").ZoneId(None).Capacity(squad.Capacity)) - UpdateSquadDetail(squad.GUID, squad) - case None => ; + val squad = lSquadOpt.getOrElse(StartSquad(tplayer)) + 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() + }) + squad.LocationFollowsSquadLead = false + squad.AutoApproveInvitationRequests = false + UpdateSquadListWhenListed(squad, SquadInfo().Task("").ZoneId(None).Capacity(squad.Capacity)) + UpdateSquadDetailWhenListed(squad.GUID, squad) + + case _ => + } + // etc.. + (pSquadOpt, action) match { + //the following action can be performed by the squad leader and maybe an unaffiliated player + case (Some(squad), SelectRoleForYourself(position)) => + log.info(s"${tplayer.Name} would like the #${position+1} spot in this squad") + val membership = squad.Membership.zipWithIndex + val toMember = squad.Membership(position) + //the squad leader may swap to any open position; a normal member has to validate against requirements + if((squad.Leader.CharId == tplayer.CharId && toMember.CharId == 0) || ValidOpenSquadPosition(squad, position, toMember, tplayer.Certifications)) { + membership.find { case (member, _) => member.CharId == tplayer.CharId } match { + case Some((fromMember, fromIndex)) => + SwapMemberPosition(squad, toMember, fromMember) + if(fromIndex == squad.LeaderPositionIndex) { + squad.LeaderPositionIndex = position + } + membership + .filter { case (_member, _) => _member.CharId > 0 } + .foreach { case (_member, _) => + SquadEvents.publish(SquadServiceResponse(s"/${_member.CharId}/Squad", SquadResponse.AssignMember(squad, fromIndex, position))) + } + UpdateSquadDetailWhenListed(squad.GUID, squad) + case _ => ; + //somehow, this is not our squad; do nothing, for now + } + } + else { + //not qualified for requested position } - case DisplaySquad() => + //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 idToSquad.get(guid) match { case Some(squad) => - sender ! SquadServiceResponse(s"${tplayer.Name}/Squad", GenSquadDetail(squad.GUID, squad)) + val toMember = squad.Membership(position) + if(ValidOpenSquadPosition(squad, position, toMember, tplayer.Certifications)) { + //we could join but we may need permission from the squad leader first + log.info(s"Player ${tplayer.Name} would like to join the squad ${squad.Task}.") + bids(tplayer.CharId) = NormalBid(squad.Leader.CharId, guid, position) + //val leader = squad.Leader + //TODO ask permission from the squad leader, unless auto-approve is in effect + self ! SquadServiceMessage(tplayer, SquadAction.Membership(SquadRequestType.Accept, tplayer.CharId, None, "", None)) + } case None => ; + //squad does not exist? assume old local data; force update to correct discrepancy } -// case AnswerSquadJoinRequest() => -// idToSquad.get(guid) match { -// case Some(squad) => -// if(squad.Leader.Name == tplayer.Name && squad.Listed) { -// //squad was just listed but we have not yet declared ourselves as the leader -// UpdateSquadDetail(squad) -// } -// case None => ; -// } + //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 { + case (Some((fromMember, fromPosition)), (toMember, _)) => + val name = fromMember.Name + SwapMemberPosition(squad, toMember, fromMember) + // + membership + .filter({ case (_member, _) => _member.CharId > 0 }) + .foreach { case (_member, _) => + SquadEvents.publish(SquadServiceResponse(s"/${_member.CharId}/Squad", SquadResponse.AssignMember(squad, fromPosition, position))) + } + UpdateSquadDetailWhenListed(squad.GUID, squad, + 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 performed by anyone + case (_, DisplaySquad()) => + idToSquad.get(guid) match { + case Some(squad) => + viewDetails(tplayer.CharId) = guid + sender ! SquadServiceResponse(s"/${tplayer.CharId}/Squad", SquadResponse.Detail(squad.GUID, SquadService.Detail.Publish(squad))) + case None => ; + } case _ => ; } @@ -595,15 +612,15 @@ class SquadService extends Actor { def JoinSquad(player : Player, squad : Squad, line : Int) : Boolean = { val charId = player.CharId val position = squad.Membership(line) - if(squad.Availability(line) && position.CharId == 0 && - player.Certifications.intersect(position.Requirements) == position.Requirements) { + if(ValidOpenSquadPosition(squad, line, position, player.Certifications)) { + log.info(s"Player ${player.Name} will join the squad ${squad.Task} at position ${line+1}!") position.Name = player.Name position.CharId = charId position.Health = StatConverter.Health(player.Health, player.MaxHealth, min=1, max=64) position.Armor = StatConverter.Health(player.Armor, player.MaxArmor, min=1, max=64) position.Position = player.Position position.ZoneId = 13 - memberToSquad(player.CharId) = squad + memberToSquad(charId) = squad val size = squad.Size if(size == 1) { @@ -612,28 +629,34 @@ class SquadService extends Actor { } else if(size == 2) { //first squad member after leader; both members fully initialize - val indices = squad.Membership.zipWithIndex.collect({ case (member, index) if member.CharId != 0 => index }).toList + val indices = squad.Membership.zipWithIndex + .collect({ case (member, index) if member.CharId != 0 => index }).toList squad.Membership - .collect({ case member if member.CharId != 0 => member.CharId }) - .foreach { charId => - SquadEvents.publish(SquadServiceResponse(s"$charId/Squad", SquadResponse.Join(squad, indices))) + .filterNot { _.CharId == 0 } + .foreach { member => + SquadEvents.publish(SquadServiceResponse(s"/${member.CharId}/Squad", SquadResponse.Join(squad, indices))) } + //fully update for all users + UpdateSquadDetail(squad.GUID, squad) } else { //joining an active squad; everybody updates differently //new member gets full UI updates - sender ! SquadServiceResponse("", SquadResponse.Join( - squad, - squad.Membership.zipWithIndex.collect({ case (member, index) if member.CharId != 0 => index }).toList - )) + val indices = squad.Membership.zipWithIndex + .collect({ case (member, index) if member.CharId != 0 => index }).toList + SquadEvents.publish(SquadServiceResponse(s"/$charId/Squad", SquadResponse.Join(squad, indices))) //other squad members see us joining the squad val updatedIndex = List(line) squad.Membership - .collect({ case member if member.CharId != 0 && member.CharId != charId => member.CharId }) - .foreach { charId => - SquadEvents.publish(SquadServiceResponse(s"$charId/Squad", SquadResponse.Join(squad, updatedIndex))) + .filterNot { member => member.CharId == 0 || member.CharId == charId } + .foreach { member => + SquadEvents.publish(SquadServiceResponse(s"/${member.CharId}/Squad", SquadResponse.Join(squad, updatedIndex))) } + UpdateSquadDetailWhenListed(squad.GUID, squad, + SquadDetail().Members(List(SquadPositionEntry(line, SquadPositionDetail().CharId(charId).Name(player.Name)))) + ) } + UpdateSquadListWhenListed(squad, SquadInfo().Size(size)) true } else { @@ -641,6 +664,86 @@ class SquadService extends Actor { } } + def LeaveSquad(player : Player, squad : Squad) : Boolean = { + val charId = player.CharId + val membership = squad.Membership.zipWithIndex + membership.find { case (_member, _) => _member.CharId == charId } match { + case Some((member, index)) => + val updateList = membership.collect({ case (_member, _index) if _member.CharId > 0 => (_member.CharId, _index) }).toList + memberToSquad.remove(charId) + member.Name = "" + member.CharId = 0 + + val size = squad.Size + if(size < 2) { + //squad is rendered to just one person or less; collapse it + squad.Membership.foreach { _member => + SquadEvents.publish(SquadServiceResponse(s"/${_member.CharId}/Squad", SquadResponse.Membership(SquadResponseType.Disband, 0, 0, charId, None, "", false, Some(None)))) + } + CloseOutSquad(squad, membership, updateList) + } + else { + //squad continues, despite player's parting + //member leaves the squad completely + sender ! SquadServiceResponse("", SquadResponse.Leave(squad, updateList)) + //other squad members see the member leaving + val leavingMember = List((charId, index)) + membership + .filter { case (_member, _) => _member.CharId > 0 } + .foreach { case (_member, _) => + SquadEvents.publish( SquadServiceResponse(s"/${_member.CharId}/Squad", SquadResponse.Leave(squad, leavingMember)) ) + } + } + true + case None => + false + } + } + + def CloseOutSquad(squad : Squad) : Unit = { + val membership = squad.Membership.zipWithIndex + CloseOutSquad( + squad, + membership, + membership.collect({ case (_member, _index) if _member.CharId > 0 => (_member.CharId, _index) }).toList + ) + } + + def CloseOutSquad(squad : Squad, membership : Iterable[(Member, Int)], updateList : List[(Long, Int)]) : Unit = { + val leaderCharId = squad.Leader.CharId + membership.foreach { + case (member, _) => + val charId = member.CharId + member.Name = "" + member.CharId = 0L + memberToSquad.remove(charId) + SquadEvents.publish( SquadServiceResponse(s"/$charId/Squad", SquadResponse.Leave(squad, updateList)) ) + } + idToSquad.remove(squad.GUID) + } + + def SwapMemberPosition(squad : Squad, 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 + } + def UpdateSquadList(squad : Squad, changes : SquadInfo) : Unit = { UpdateSquadList(squad, Some(changes)) } @@ -650,7 +753,7 @@ class SquadService extends Actor { } def UpdateSquadListWhenListed(squad : Squad, changes : Option[SquadInfo]) : Unit = { - if(squad.Listed) { + if(squad.Listed || squad.Size > 1) { UpdateSquadList(squad, changes) } } @@ -673,7 +776,7 @@ class SquadService extends Actor { log.info(s"Squad will be updated") factionListings(index) = entry SquadEvents.publish( - SquadServiceResponse(s"$faction/Squad", SquadResponse.UpdateList(Seq((index, changedFields)))) + SquadServiceResponse(s"/$faction/Squad", SquadResponse.UpdateList(Seq((index, changedFields)))) ) case None => //remove squad from listing @@ -681,7 +784,7 @@ class SquadService extends Actor { factionListings.remove(index) SquadEvents.publish( //SquadServiceResponse(s"$faction/Squad", SquadResponse.RemoveFromList(Seq(index))) - SquadServiceResponse(s"$faction/Squad", SquadResponse.InitList(factionListings.toVector)) + SquadServiceResponse(s"/$faction/Squad", SquadResponse.InitList(factionListings.toVector)) ) } case None => @@ -689,40 +792,33 @@ class SquadService extends Actor { log.info(s"Squad will be introduced") factionListings += entry SquadEvents.publish( - SquadServiceResponse(s"$faction/Squad", SquadResponse.InitList(factionListings.toVector)) + SquadServiceResponse(s"/$faction/Squad", SquadResponse.InitList(factionListings.toVector)) ) } } - def GenSquadDetail(guid : PlanetSideGUID, squad : Squad) : SquadResponse.Detail = { - SquadResponse.Detail( - squad.GUID, - squad.Leader.Name, - squad.Task, - PlanetSideZoneID(squad.ZoneId), - squad.Membership.zipWithIndex.map({ case (p, index) => - if(squad.Availability(index)) { - SquadPositionDetail(p.Role, p.Orders, p.Requirements, p.CharId, p.Name) - } - else { - SquadPositionDetail.Closed - } - }).toList - ) + def UpdateSquadDetail(guid : PlanetSideGUID, squad : Squad) : Unit = { + UpdateSquadDetailWhenListed(guid, squad, SquadService.Detail.Publish(squad)) } - def UpdateListedSquadDetail(guid : PlanetSideGUID, squad : Squad) : Unit = { - if(squad.Listed) { - UpdateSquadDetail(guid, squad) + def UpdateSquadDetailWhenListed(guid : PlanetSideGUID, squad : Squad) : Unit = { + if(squad.Listed || squad.Size > 1) { + UpdateSquadDetail(guid, squad, SquadService.Detail.Publish(squad)) } } - def UpdateSquadDetail(guid : PlanetSideGUID, squad : Squad) : Unit = { - val detail = GenSquadDetail(guid, squad) + def UpdateSquadDetailWhenListed(guid : PlanetSideGUID, squad : Squad, detail : SquadDetail) : Unit = { + if(squad.Listed || squad.Size > 1) { + UpdateSquadDetail(guid, squad, detail) + } + } + + def UpdateSquadDetail(guid : PlanetSideGUID, squad : Squad, detail : SquadDetail) : Unit = { + val output = SquadResponse.Detail(guid, detail) squad.Membership - .collect { case member if member.CharId > 0L => member.CharId } - .foreach { charId => - SquadEvents.publish(SquadServiceResponse(s"$charId/Squad", detail)) + .filter { _.CharId > 0L } + .foreach { member => + SquadEvents.publish(SquadServiceResponse(s"/${member.CharId}/Squad", output)) } } } @@ -734,7 +830,7 @@ object SquadService { final case class VacancyBid(char_id : Long, squad_guid : PlanetSideGUID) extends PositionBid - final case class CamraderieBid(char_id : Long, placeholder : Squad) extends PositionBid + final case class SpontaneousBid(char_id : Long, placeholder : Squad) extends PositionBid object SquadList { def Publish(squad : Squad) : SquadInfo = { @@ -768,86 +864,6 @@ object SquadService { ) .Complete } - - def Differences(updates : List[Int], info : SquadDetail) : SquadDetail = { - if(updates.nonEmpty) { - val list = Seq( - SquadDetail.Blank, //must be index-0 - SquadDetail(info.unk1, None, None, None, None, None, None, None, None), - SquadDetail(None, None, info.leader_char_id, None, None, None, None, None, None), - SquadDetail(None, None, None, info.unk3, None, None, None, None, None), - SquadDetail(None, None, None, None, info.leader_name, None, None, None, None), - SquadDetail(None, None, None, None, None, info.task, None, None, None), - SquadDetail(None, None, None, None, None, None, info.zone_id, None, None), - SquadDetail(None, None, None, None, None, None, None, info.unk7, None), - SquadDetail(None, None, None, None, None, None, None, None, info.member_info) - ) - var out = SquadDetail.Blank - updates - .map(i => list(i)) - .filterNot { _ == SquadDetail.Blank } - .foreach(sinfo => out = out And sinfo ) - out - } - else { - SquadDetail.Blank - } - } - - def Differences(before : SquadDetail, after : SquadDetail) : SquadDetail = { - SquadDetail( - if(!before.unk1.equals(after.unk1)) after.unk1 else None, - None, - if(!before.leader_char_id.equals(after.leader_char_id)) after.leader_char_id else None, - if(!before.unk3.equals(after.unk3)) after.unk3 else None, - if(!before.leader_name.equals(after.leader_name)) after.leader_name else None, - if(!before.task.equals(after.task)) after.task else None, - if(!before.zone_id.equals(after.zone_id)) after.zone_id else None, - if(!before.unk7.equals(after.unk7)) after.unk7 else None, - { - (before.member_info, after.member_info) match { - case (Some(beforeEntry), Some(afterEntry)) => - val out = beforeEntry.zip(afterEntry) - .map { case (b, a) => PositionEquality(b, a) } - .collect { case Some(entry) => entry } - if(out.nonEmpty) { - Some(out) - } - else { - None - } - case _ => - None - } - } - ) - } - - private def PositionEquality(before : SquadPositionEntry, after : SquadPositionEntry) : Option[SquadPositionEntry] = { - (before.info, after.info) match { - case (Some(binfo), Some(ainfo)) => - val out = MemberEquality(binfo, ainfo) - if(out == SquadPositionDetail.Blank) { - None - } - else { - Some(SquadPositionEntry(before.index, out)) - } - case _ => - None - } - } - - private def MemberEquality(before : SquadPositionDetail, after : SquadPositionDetail) : SquadPositionDetail = { - SquadPositionDetail( - if(!before.is_closed.equals(after.is_closed)) after.is_closed else None, - if(!before.role.equals(after.role)) after.role else None, - if(!before.detailed_orders.equals(after.detailed_orders)) after.detailed_orders else None, - if(!before.requirements.equals(after.requirements)) after.requirements else None, - if(!before.char_id.equals(after.char_id)) after.char_id else None, - if(!before.name.equals(after.name)) after.name else None - ) - } } def LoadSquadDefinition(squad : Squad, favorite : SquadLoadout) : Unit = { @@ -869,6 +885,10 @@ object SquadService { } def ValidOpenSquadPosition(squad : Squad, position : Int, member : Member, reqs : Set[CertificationType.Value]) : Boolean = { - squad.Availability(position) && member.CharId == 0 && reqs.intersect(member.Requirements) == member.Requirements + ValidSquadPosition(squad, position, member, reqs) && member.CharId == 0 + } + + def ValidSquadPosition(squad : Squad, position : Int, member : Member, reqs : Set[CertificationType.Value]) : Boolean = { + squad.Availability(position) && reqs.intersect(member.Requirements) == member.Requirements } } 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/pslogin/src/main/scala/WorldSessionActor.scala b/pslogin/src/main/scala/WorldSessionActor.scala index 37619aeca..49fdca7e5 100644 --- a/pslogin/src/main/scala/WorldSessionActor.scala +++ b/pslogin/src/main/scala/WorldSessionActor.scala @@ -376,19 +376,8 @@ class WorldSessionActor extends Actor with MDCContextAware { ) ) - case SquadResponse.Detail(guid, leader, task, zone, member_info) => - sendResponse( - SquadDetailDefinitionUpdateMessage( - guid, - SquadDetail() - .LeaderCharId(member_info.find(_.name.contains(leader)).get.char_id.get) - .LeaderName(leader) - .Task(task) - .ZoneId(zone) - .Members(member_info.zipWithIndex.map { case (a, b) => SquadPositionEntry(b, a) }) - .Complete - ) - ) + case SquadResponse.Detail(guid, detail) => + sendResponse(SquadDetailDefinitionUpdateMessage(guid, detail)) case SquadResponse.InitSquad(squad_guid) => sendResponse(SquadDefinitionActionMessage(squad_guid, 0, SquadAction.Unknown(16))) @@ -414,7 +403,6 @@ class WorldSessionActor extends Actor with MDCContextAware { membershipPositions.find({ case(member, _) => member.CharId == avatar.CharId }) match { case Some((ourMember, ourIndex)) => //we are joining the squad - sendResponse(SquadMembershipResponse(SquadResponseType.Accept, 0, 0, player.CharId, Some(leader.CharId), player.Name, true, Some(None))) //load each member's entry (our own too) membershipPositions.foreach { case(member, index) => sendResponse(SquadMemberEvent(0, id, member.CharId, index, Some(member.Name), Some(member.ZoneId), Some(0))) @@ -447,7 +435,6 @@ class WorldSessionActor extends Actor with MDCContextAware { positionsToUpdate.find({ case(member, _) => member == avatar.CharId }) match { case Some((ourMember, ourIndex)) => //we are leaving the squad - sendResponse(SquadMembershipResponse(SquadResponseType.Leave, 0,1, avatar.CharId, Some(avatar.CharId), avatar.name, true, Some(None))) //remove each member's entry (our own too) positionsToUpdate.foreach { case(member, index) => sendResponse(SquadMemberEvent(1, id, member, index, None, None, None)) @@ -498,23 +485,53 @@ class WorldSessionActor extends Actor with MDCContextAware { ) } - case SquadResponse.SwapMember(squad, to_index, from_index) => - //this failsafe is not supported by normal squad member operations - val member = squad.Membership(to_index) - val charId = member.CharId - val elem = squadUI(charId) - val id = 11 - squadUI(charId) = SquadUIElement(elem.name, to_index, elem.zone, elem.health, elem.armor, elem.position) - sendResponse(SquadMemberEvent(1, id, charId, from_index, None, None, None)) - sendResponse(SquadMemberEvent(0, id, charId, to_index, Some(elem.name), Some(elem.zone), Some(0))) - sendResponse( - SquadState( - PlanetSideGUID(id), - List(SquadStateInfo(charId, elem.health, elem.armor, elem.position, 2,2, false, 429, None,None)) - ) - ) - if(charId == avatar.CharId) { - sendResponse(PlanetsideAttributeMessage(player.GUID, 32, to_index)) + case SquadResponse.AssignMember(squad, from_index, to_index) => + if(squadUI.nonEmpty) { + val toMember = squad.Membership(to_index) + val toCharId = toMember.CharId + val fromMember = squad.Membership(from_index) + val fromCharId = fromMember.CharId + val id = 11 + if(fromCharId > 0) { + //toMember and fromMember have swapped places + val fromElem = squadUI(fromCharId) + val toElem = squadUI(toCharId) + sendResponse(SquadMemberEvent(1, id, toCharId, from_index, None, None, None)) + sendResponse(SquadMemberEvent(1, id, fromCharId, to_index, None, None, None)) + squadUI(toCharId) = SquadUIElement(fromElem.name, to_index, fromElem.zone, fromElem.health, fromElem.armor, fromElem.position) + squadUI(fromCharId) = SquadUIElement(toElem.name, from_index, toElem.zone, toElem.health, toElem.armor, toElem.position) + sendResponse(SquadMemberEvent(0, id, toCharId, to_index, Some(fromElem.name), Some(fromElem.zone), Some(0))) + sendResponse(SquadMemberEvent(0, id, fromCharId, from_index, Some(toElem.name), Some(toElem.zone), Some(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) + sendResponse(SquadMemberEvent(1, id, toCharId, from_index, None, None, None)) + squadUI(toCharId) = SquadUIElement(elem.name, to_index, elem.zone, elem.health, elem.armor, elem.position) + sendResponse(SquadMemberEvent(0, id, toCharId, to_index, Some(elem.name), Some(elem.zone), Some(0))) + sendResponse( + SquadState( + PlanetSideGUID(id), + List(SquadStateInfo(toCharId, elem.health, elem.armor, elem.position, 2, 2, false, 429, None, None)) + ) + ) + } + val charId = avatar.CharId + if(toCharId == charId) { + sendResponse(PlanetsideAttributeMessage(player.GUID, 32, to_index)) + } + else if(fromCharId == charId) { + sendResponse(PlanetsideAttributeMessage(player.GUID, 32, from_index)) + } } case _ => ; @@ -3454,7 +3471,32 @@ class WorldSessionActor extends Actor with MDCContextAware { case msg @ PlayerStateMessageUpstream(avatar_guid, pos, vel, yaw, pitch, yaw_upper, seq_time, unk3, is_crouching, is_jumping, unk4, is_cloaking, unk5, unk6) => if(deadState == DeadState.Alive) { if(!player.Crouching && is_crouching) { //SQUAD TESTING CODE - sendResponse(SquadMembershipResponse(SquadResponseType.Unk01, 0, 0, player.CharId, None, "Dummy", false, Some(None))) + sendResponse( + CharacterKnowledgeMessage( + 41577140L, + CharacterKnowledgeInfo( + "Degrado", + 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 + ), + 9, + 0, + PlanetSideGUID(1) + ) + ) + ) + sendResponse(SquadInvitationRequestMessage(PlanetSideGUID(1), 9, 41577140L, "Degrado")) } player.Position = pos player.Velocity = vel