diff --git a/common/src/main/scala/net/psforever/objects/teamwork/Member.scala b/common/src/main/scala/net/psforever/objects/teamwork/Member.scala index fc1988e3a..46d9e15ff 100644 --- a/common/src/main/scala/net/psforever/objects/teamwork/Member.scala +++ b/common/src/main/scala/net/psforever/objects/teamwork/Member.scala @@ -6,12 +6,13 @@ import net.psforever.types.{CertificationType, Vector3} class Member { //about the position to be filled private var role : String = "" + private var orders : String = "" private var restrictions : Set[CertificationType.Value] = Set() //about the individual filling the position private var name : String = "" private var health : Int = 0 private var armor : Int = 0 - private var zoneId : String = "Nowhere" + private var zoneId : Int = 0 private var position : Vector3 = Vector3.Zero def Role : String = role @@ -21,6 +22,13 @@ class Member { Role } + def Orders : String = orders + + def Orders_=(text : String) : String = { + orders = text + Orders + } + def Restrictions : Set[CertificationType.Value] = restrictions def Restrictions_=(requirements : Set[CertificationType.Value]) = { @@ -49,9 +57,9 @@ class Member { Armor } - def ZoneId : String = zoneId + def ZoneId : Int = zoneId - def ZoneId_=(id : String) : String = { + def ZoneId_=(id : Int) : Int = { zoneId = id ZoneId } @@ -62,4 +70,15 @@ class Member { position = pos Position } + + def Close() : Unit = { + role = "" + restrictions = Set() + //about the individual filling the position + name = "" + health = 0 + armor = 0 + zoneId = 0 + position = Vector3.Zero + } } diff --git a/common/src/main/scala/net/psforever/objects/teamwork/Squad.scala b/common/src/main/scala/net/psforever/objects/teamwork/Squad.scala index 23a96a7a7..071d8a8eb 100644 --- a/common/src/main/scala/net/psforever/objects/teamwork/Squad.scala +++ b/common/src/main/scala/net/psforever/objects/teamwork/Squad.scala @@ -8,25 +8,35 @@ import net.psforever.types.PlanetSideEmpire class Squad(squadId : PlanetSideGUID, alignment : PlanetSideEmpire.Value) extends IdentifiableEntity { super.GUID_=(squadId) private val faction : PlanetSideEmpire.Value = alignment //does not change - private val zoneId : Option[String] = None + private var zoneId : Option[Int] = None private var task : String = "" private var description : String = "" private val membership : Array[Member] = Array.fill[Member](10)(new Member) private val availability : Array[Boolean] = Array.fill[Boolean](10)(true) + private var listed : Boolean = false override def GUID_=(d : PlanetSideGUID) : PlanetSideGUID = GUID def Faction : PlanetSideEmpire.Value = faction - def ZoneId : String = zoneId.getOrElse({ + def ZoneId : Int = zoneId.getOrElse({ membership.headOption match { case Some(leader) => leader.ZoneId case _ => - "Nowhere" + 0 } }) + 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 = { @@ -41,10 +51,26 @@ class Squad(squadId : PlanetSideGUID, alignment : PlanetSideEmpire.Value) extend Description } + def Listed : Boolean = listed + + def Listed_=(announce : Boolean) : Boolean = { + listed = announce + Listed + } + def Membership : Array[Member] = membership def Availability : Array[Boolean] = availability + def Leader : String = { + membership.headOption match { + case Some(member) => + member.Name + case None => + "" + } + } + def Size : Int = membership.count(member => !member.Name.equals("")) def Capacity : Int = availability.count(open => open) 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 0baf8518a..5b714fc81 100644 --- a/common/src/main/scala/net/psforever/packet/game/SquadDefinitionActionMessage.scala +++ b/common/src/main/scala/net/psforever/packet/game/SquadDefinitionActionMessage.scala @@ -12,7 +12,7 @@ import shapeless.{::, HNil} * All behaviors have a "code" that indicates how the rest of the data is parsed. * @param code the action behavior code */ -protected abstract class SquadAction(val code : Int) +abstract class SquadAction(val code : Int) object SquadAction{ final case class SaveSquadDefinition() extends SquadAction(3) 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/services/teamwork/SquadResponse.scala b/common/src/main/scala/services/teamwork/SquadResponse.scala new file mode 100644 index 000000000..09f05fb36 --- /dev/null +++ b/common/src/main/scala/services/teamwork/SquadResponse.scala @@ -0,0 +1,12 @@ +// Copyright (c) 2019 PSForever +package services.teamwork + +import net.psforever.packet.game.SquadInfo + +object SquadResponse { + trait Response + + final case class Init(info : Vector[SquadInfo]) extends Response + final case class Update(infos : Iterable[(Int, SquadInfo)]) extends Response + final case class Remove(infos : Iterable[Int]) extends Response +} 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..4d58a1af1 --- /dev/null +++ b/common/src/main/scala/services/teamwork/SquadService.scala @@ -0,0 +1,276 @@ +// Copyright (c) 2019 PSForever +package services.teamwork + +import akka.actor.Actor +import net.psforever.objects.Player +import net.psforever.objects.teamwork.Squad +import net.psforever.packet.game._ +import net.psforever.types.PlanetSideEmpire +import services.{GenericEventBus, Service} + +import scala.collection.concurrent.TrieMap +import scala.collection.mutable.ListBuffer + +//import scala.concurrent.duration._ + +class SquadService extends Actor { + private var memberToSquad : TrieMap[String, Squad] = new TrieMap[String, Squad]() + private var idToSquad : TrieMap[PlanetSideGUID, Squad] = new TrieMap[PlanetSideGUID, Squad]() + private var i : Int = 1 + private var publishedLists : TrieMap[PlanetSideEmpire.Value, ListBuffer[SquadInfo]] = TrieMap[PlanetSideEmpire.Value, ListBuffer[SquadInfo]]( + PlanetSideEmpire.TR -> ListBuffer.empty, + PlanetSideEmpire.NC -> ListBuffer.empty, + PlanetSideEmpire.VS -> ListBuffer.empty + ) + + private [this] val log = org.log4s.getLogger + + override def preStart = { + log.info("Starting...") + } + + def GetNextSquadId() : PlanetSideGUID = { + val out = i + val j = i + 1 + if(j == 65536) { + i = 1 + } + else { + i = j + } + PlanetSideGUID(out) + } + + def GetSquadFromPlayer(player : Player) : Squad = { + val name = player.Name + val faction = player.Faction + memberToSquad.get(name) match { + case Some(squad) => + squad + case None => + val id = GetNextSquadId() + val squad = new Squad(id, faction) + val leadPosition = squad.Membership(0) + leadPosition.Name = name + leadPosition.Health = player.Health + leadPosition.Armor = player.Armor + leadPosition.Position = player.Position + leadPosition.ZoneId = 1 //player.Continent //TODO how to resolve this? + log.info(s"$name-$faction has started a new squad") + memberToSquad += name -> squad + idToSquad += id -> squad + squad + } + } + + val SquadEvents = new GenericEventBus[SquadServiceResponse] + + 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() + log.info(s"$who has joined $path") + SquadEvents.subscribe(who, path) + //send initial squad catalog + sender ! SquadServiceResponse(s"$faction/Squad", SquadResponse.Init(publishedLists(PlanetSideEmpire(faction)).toVector)) + + //subscribe to the player's personal channel - necessary only to inform about any previous squad association + case Service.Join(name) => + val path = s"$name/Squad" + val who = sender() + log.info(s"$who has joined $path") + SquadEvents.subscribe(who, path) + //check for renewable squad information + memberToSquad.get(name) match { + case None => ; + case Some(squad) => + sender ! SquadServiceMessage.RecoverSquadMembership() + } + + case Service.Leave(Some(name)) => ; + SquadEvents.unsubscribe(sender()) + //TODO leave squad, if joined to one, and perform clean-up + + case Service.Leave(None) | Service.LeaveAll() => + SquadEvents.unsubscribe(sender()) + //TODO might be better to invalidate these + + case SquadServiceMessage.SquadDefinitionAction(tplayer, zone_ordinal_number, _, _, action) => + import net.psforever.packet.game.SquadAction._ + val squad = GetSquadFromPlayer(tplayer) + val member = squad.Membership.find(_.Name == tplayer.Name).get //should never fail + member.ZoneId = zone_ordinal_number //TODO improve this requirement + var listingChanged : List[Int] = Nil + action match { + case ChangeSquadPurpose(purpose) => + log.info(s"${tplayer.Name}-${tplayer.Faction} has changed his squad's task to $purpose") + squad.Description = purpose + listingChanged = List(SquadInfo.Field.Task) + + case ChangeSquadZone(zone) => + log.info(s"${tplayer.Name}-${tplayer.Faction} has changed his squad's ops zone to $zone") + squad.ZoneId = zone.zoneId.toInt + listingChanged = List(SquadInfo.Field.ZoneId) + + case CloseSquadMemberPosition(position) => + if(position > 0) { + squad.Availability.lift(position) match { + case Some(true) => + squad.Availability.update(position, false) + log.info(s"${tplayer.Name}-${tplayer.Faction} has closed the #$position position in his squad") + val memberPosition = squad.Membership(position) + listingChanged = if(memberPosition.Name.nonEmpty) { + List(SquadInfo.Field.Size, SquadInfo.Field.Capacity) + } + else { + List(SquadInfo.Field.Capacity) + } + memberPosition.Close() + case Some(false) => ; + case None => ; + } + } + else { + log.warn(s"can not close the lead position in squad-${squad.GUID.guid}") + } + + case AddSquadMemberPosition(position) => + squad.Availability.lift(position) match { + case Some(false) => + log.info(s"${tplayer.Name}-${tplayer.Faction} has opened the #$position position in his squad") + squad.Availability.update(position, true) + listingChanged = List(SquadInfo.Field.Capacity) + case Some(true) => ; + case None => ; + } + + case ChangeSquadMemberRequirementsRole(position, role) => + squad.Availability.lift(position) match { + case Some(true) => + squad.Membership(position).Role = role + case Some(false) => ; + case None => ; + } + + case ChangeSquadMemberRequirementsDetailedOrders(position, orders) => + squad.Availability.lift(position) match { + case Some(true) => + squad.Membership(position).Orders = orders + case Some(false) => ; + case None => ; + } + + case ListSquad() => + if(!squad.Listed) { + log.info(s"${tplayer.Name}-${tplayer.Faction} has opened recruitment for his squad") + squad.Listed = true + } + + case ResetAll() => + squad.Description = "" + squad.ZoneId = None + squad.Availability.indices.foreach { i => + squad.Availability.update(i, true) + } + //TODO squad members? + + case _ => ; + } + //queue updates + if(squad.Listed) { + val entry = SquadService.Publish(squad) + val faction = squad.Faction + val factionListings = publishedLists(faction) + factionListings.find(info => { + info.squad_guid match { + case Some(guid) => guid == squad.GUID + case _ => false + } + }) match { + case Some(listedSquad) => + val index = factionListings.indexOf(listedSquad) + if(squad.Listed) { + //squad information update + log.info(s"Squad will be updated") + factionListings(index) = entry + val changes = if(listingChanged.nonEmpty) { + SquadService.Differences(listingChanged, entry) + } + else { + SquadService.Differences(listedSquad, entry) + } + if(changes != SquadInfo.Blank) { + SquadEvents.publish( + SquadServiceResponse(s"$faction/Squad", SquadResponse.Update(Seq((index, changes)))) + ) + } + } + else { + //remove squad from listing + log.info(s"Squad will be removed") + factionListings.remove(index) + SquadEvents.publish( + SquadServiceResponse(s"$faction/Squad", SquadResponse.Remove(Seq(index))) + ) + } + case None if squad.Listed => + log.info(s"Squad will be introduced") + //first time being published? + factionListings += SquadService.Publish(squad) + SquadEvents.publish( + SquadServiceResponse(s"$faction/Squad", SquadResponse.Init(factionListings.toVector)) + ) + case _ => ; + } + } + + case msg => + log.info(s"Unhandled message $msg from $sender") + } +} + +object SquadService { + def Publish(squad : Squad) : SquadInfo = { + SquadInfo( + squad.Leader, + squad.Description, + PlanetSideZoneID(squad.ZoneId), + squad.Size, + squad.Capacity, + squad.GUID + ) + } + + def Differences(updates : List[Int], info : SquadInfo) : SquadInfo = { + if(updates.nonEmpty) { + var out = SquadInfo.Blank + ({ + val list = Seq( + SquadInfo.Blank, //must be index-0 + SquadInfo(info.leader, None, None, None, None), + SquadInfo(None, info.task, None, None, None), + SquadInfo(None, None, info.zone_id, None, None), + SquadInfo(None, None, None, info.size, None), + SquadInfo(None, None, None, None, info.capacity) + ) + updates.map(i => list(i)).filterNot { _ == SquadInfo.Blank } + }) //ignore what code inspection tells you - the parenthesis is necessary + .foreach(sinfo => out = out And sinfo ) + out + } + else { + SquadInfo.Blank + } + } + + def Differences(before : SquadInfo, after : SquadInfo) : SquadInfo = { + SquadInfo( + if(!before.leader.equals(after.leader)) after.leader 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.size.equals(after.size)) after.size else None, + if(!before.capacity.equals(after.capacity)) after.capacity else None + ) + } +} 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..5f9187878 --- /dev/null +++ b/common/src/main/scala/services/teamwork/SquadServiceMessage.scala @@ -0,0 +1,13 @@ +// Copyright (c) 2019 PSForever +package services.teamwork + +import net.psforever.objects.Player +import net.psforever.packet.game.SquadAction + +final case class SquadServiceMessage(forChannel : String, actionMessage : Any) + +object SquadServiceMessage { + final case class SquadDefinitionAction(player : Player, zone_ordinal_number : Int, u1 : Int, u2 : Int, action : SquadAction) + + final case class RecoverSquadMembership() +} 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..5e6bc998c --- /dev/null +++ b/common/src/main/scala/services/teamwork/SquadServiceResponse.scala @@ -0,0 +1,7 @@ +// Copyright (c) 2019 PSForever +package services.teamwork + +import net.psforever.packet.game.SquadInfo +import services.GenericEventBusMsg + +final case class SquadServiceResponse(toChannel : String, response : SquadResponse.Response) extends GenericEventBusMsg diff --git a/pslogin/src/main/scala/PsLogin.scala b/pslogin/src/main/scala/PsLogin.scala index 581837462..e2a3b9484 100644 --- a/pslogin/src/main/scala/PsLogin.scala +++ b/pslogin/src/main/scala/PsLogin.scala @@ -21,6 +21,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._ @@ -216,6 +217,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 c33ba93a1..170ede0e2 100644 --- a/pslogin/src/main/scala/WorldSessionActor.scala +++ b/pslogin/src/main/scala/WorldSessionActor.scala @@ -52,6 +52,7 @@ 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._ import scala.concurrent.duration._ import scala.concurrent.ExecutionContext.Implicits.global @@ -76,6 +77,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 @@ -266,6 +268,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 +294,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 +342,37 @@ class WorldSessionActor extends Actor with MDCContextAware { case VehicleServiceResponse(toChannel, guid, reply) => HandleVehicleServiceResponse(toChannel, guid, reply) + case SquadServiceResponse(toChannel, response) => + response match { + case SquadResponse.Init(infos) if infos.nonEmpty => + sendResponse(ReplicationStreamMessage(infos)) + + case SquadResponse.Update(infos) if infos.nonEmpty => + val o = ReplicationStreamMessage(6, None, + infos.map { case (index, squadInfo) => + SquadListing(index, squadInfo) + }.toVector + ) + log.info(s"updating squad with msg $o") + sendResponse( + ReplicationStreamMessage(6, None, + infos.map { case (index, squadInfo) => + SquadListing(index, squadInfo) + }.toVector + ) + ) + + case SquadResponse.Remove(infos) if infos.nonEmpty => + sendResponse( + ReplicationStreamMessage(1, None, + infos.map { index => + SquadListing(index, None) + }.toVector + ) + ) + case _ => ; + } + case Deployment.CanDeploy(obj, state) => val vehicle_guid = obj.GUID //TODO remove this arbitrary allowance angle when no longer helpful @@ -806,6 +843,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(avatar.name) //management of any lingering squad information connected to this player cluster ! InterstellarCluster.GetWorld("home3") case InterstellarCluster.GiveWorld(zoneId, zone) => @@ -4733,8 +4772,9 @@ 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.SquadDefinitionAction(player, continent.Number, u1, u2, action) case msg @ GenericCollisionMsg(u1, p, t, php, thp, pv, tv, ppos, tpos, u2, u3, u4) => log.info("Ouch! " + msg)