ChatActor

This removes roughly 1k LOC from WorldSessionActor and moves
them to a new ChatActor. That was the initial goal anyway, but it
wasn't that simple. There was no clear location to put this new actor,
I didn't want to put it in pslogin since it isn't part of the "login server"
(and neither is WSA). But since the new actor would have to talk to
WSA and common does not depend on pslogin, I had a choice of
putting more actors in pslogin or putting everything in common. I
chose the latter.

ChatActor and SessionActor (formerly WorldSessionActor) now live
in `common/actors/session`. Since WSA also depends on other
actors in pslogin, most of the pslogin code was moved to either
common/login or common/util. PsLogin as the main entry point
remains in pslogin since having the main code compile to a library
has some advantages, and it will allow us to produce binaries
for distinct login/world servers in the future if desired. For a second
take, I'd suggest moving common to /src in the root directory.

This change is enabled by a new immutable `Zone` object that is
passed from SessionActor to ChatActor. Most of its members are
still mutable references, and the code at the moment does depend
on this being the case. Changes to the session object in
SessionActor are forwarded through a SetZone message to
ChatActor. As we split out more code into actors, we could
use EventBus or typed Topic's instead.

Also included is a reworked ChatService that was converted to a
typed actor and uses the built-in Receptionist facility for service
discovery. By receiving the session object from ChatActor, it can
be much smarter about who to send messages to, rather than
sending all messages to everyone and having them figure it out.
But as this session object is not updated, it can only use static
properties like player name and faction and not fluid properties
like position.

The following chat commands were added:
command, note, gmbroadcast, [nc|tr|vs|broadcast, gmtell, gmpopup
and !whitetext
This commit is contained in:
Jakob Gillich 2020-07-11 12:50:29 +02:00
parent 144804139f
commit 4634dffe00
66 changed files with 1560 additions and 1838 deletions

View file

@ -0,0 +1,755 @@
package net.psforever.actors.session
import akka.actor.Cancellable
import akka.actor.typed.receptionist.Receptionist
import akka.actor.typed.{ActorRef, Behavior, PostStop, Signal}
import akka.actor.typed.scaladsl.{AbstractBehavior, ActorContext, Behaviors}
import net.psforever.objects.serverobject.pad.{VehicleSpawnControl, VehicleSpawnPad}
import net.psforever.objects.{Default, GlobalDefinitions, Player, Session}
import net.psforever.objects.serverobject.resourcesilo.ResourceSilo
import net.psforever.objects.serverobject.turret.{FacilityTurret, TurretUpgrade, WeaponTurrets}
import net.psforever.objects.zones.Zoning
import net.psforever.packet.PacketCoding
import net.psforever.packet.game.{ChatMsg, DeadState, RequestDestroyMessage, ZonePopulationUpdateMessage}
import net.psforever.types.{ChatMessageType, PlanetSideEmpire, PlanetSideGUID, Vector3}
import net.psforever.util.PointOfInterest
import net.psforever.zones.Zones
import services.chat.ChatService
import services.chat.ChatService.ChatChannel
import services.local.{LocalAction, LocalServiceMessage}
import scala.concurrent.duration._
import scala.concurrent.ExecutionContext.Implicits.global
object ChatActor {
def apply(sessionActor: ActorRef[SessionActor.Command]): Behavior[Command] =
Behaviors.setup(context => new ChatActor(context, sessionActor))
sealed trait Command
final case class JoinChannel(channel: ChatChannel) extends Command
final case class LeaveChannel(channel: ChatChannel) extends Command
final case class Message(message: ChatMsg) extends Command
final case class SetSession(session: Session) extends Command
private case class ListingResponse(listing: Receptionist.Listing) extends Command
private case class IncomingMessage(session: Session, message: ChatMsg, channel: ChatChannel) extends Command
}
class ChatActor(context: ActorContext[ChatActor.Command], sessionActor: ActorRef[SessionActor.Command])
extends AbstractBehavior[ChatActor.Command](context) {
import ChatActor._
private[this] val log = org.log4s.getLogger
var channels: List[ChatChannel] = List()
var session: Option[Session] = None
var chatService: Option[ActorRef[ChatService.Command]] = None
var silenceTimer: Cancellable = Default.Cancellable
val chatServiceAdapter: ActorRef[ChatService.MessageResponse] = context.messageAdapter[ChatService.MessageResponse] {
case ChatService.MessageResponse(session, message, channel) => IncomingMessage(session, message, channel)
}
override def onSignal: PartialFunction[Signal, Behavior[Command]] = {
case PostStop =>
silenceTimer.cancel()
if (chatService.isDefined) chatService.get ! ChatService.LeaveAllChannels(chatServiceAdapter)
this
}
override def onMessage(msg: Command): Behavior[Command] = {
import ChatMessageType._
msg match {
case ListingResponse(ChatService.ChatServiceKey.Listing(listings)) =>
chatService = Some(listings.head)
chatService.get ! ChatService.JoinChannel(chatServiceAdapter, session.get, ChatChannel.Default())
channels ++= List(ChatChannel.Default())
this
case SetSession(newSession) =>
session = Some(newSession)
if (chatService.isEmpty && newSession.player != null) { // TODO the player check sucks...
context.system.receptionist ! Receptionist.Find(
ChatService.ChatServiceKey,
context.messageAdapter[Receptionist.Listing](ListingResponse)
)
}
this
case JoinChannel(channel) =>
chatService.get ! ChatService.JoinChannel(chatServiceAdapter, session.get, channel)
channels ++= List(channel)
this
case LeaveChannel(channel) =>
chatService.get ! ChatService.LeaveChannel(chatServiceAdapter, channel)
channels = channels.filter(_ == channel)
this
/** Some messages are sent during login so we handle them prematurely because main message handler requires the
* session object and chat service and they may not be set yet
*/
case Message(ChatMsg(CMT_CULLWATERMARK, _, _, contents, _)) =>
val connectionState =
if (contents.contains("40 80")) 100
else if (contents.contains("120 200")) 25
else 50
sessionActor ! SessionActor.SetConnectionState(connectionState)
this
case Message(ChatMsg(CMT_ANONYMOUS, _, _, _, _)) =>
// ??
this
case Message(ChatMsg(CMT_TOGGLE_GM, _, _, _, _)) =>
// ??
this
case Message(message) =>
log.info("Chat: " + message)
(session, chatService) match {
case (Some(session), Some(chatService)) =>
(message.messageType, message.recipient.trim, message.contents.trim) match {
case (CMT_FLY, recipient, contents) if session.admin =>
val flying = contents match {
case "on" => true
case "off" => false
case _ => !session.flying
}
sessionActor ! SessionActor.SetFlying(flying)
sessionActor ! SessionActor.SendResponse(
ChatMsg(CMT_FLY, false, recipient, if (flying) "on" else "off", None)
)
case (CMT_SPEED, recipient, contents) =>
val speed =
try {
contents.toFloat
} catch {
case _: Throwable =>
1f
}
sessionActor ! SessionActor.SetSpeed(speed)
sessionActor ! SessionActor.SendResponse(message.copy(contents = f"$speed%.3f"))
case (CMT_TOGGLESPECTATORMODE, _, contents) if session.admin =>
val spectator = contents match {
case "on" => true
case "off" => false
case _ => !session.player.spectator
}
sessionActor ! SessionActor.SetSpectator(spectator)
sessionActor ! SessionActor.SendResponse(message.copy(contents = if (spectator) "on" else "off"))
case (CMT_RECALL, _, _) =>
val sanctuary = Zones.SanctuaryZoneId(session.player.Faction)
val errorMessage = session.zoningType match {
case Zoning.Method.Quit => Some("You can't recall to your sanctuary continent while quitting")
case Zoning.Method.InstantAction =>
Some("You can't recall to your sanctuary continent while instant actioning")
case Zoning.Method.Recall => Some("You already requested to recall to your sanctuary continent")
case _ if session.zone.Id == sanctuary =>
Some("You can't recall to your sanctuary when you are already in your sanctuary")
case _ if !session.player.isAlive || session.deadState != DeadState.Alive =>
Some(if (session.player.isAlive) "@norecall_deconstructing" else "@norecall_dead")
case _ if session.player.VehicleSeated.nonEmpty => Some("@norecall_invehicle")
case _ => None
}
errorMessage match {
case Some(errorMessage) =>
sessionActor ! SessionActor.SendResponse(
ChatMsg(
CMT_QUIT,
false,
"",
errorMessage,
None
)
)
case None =>
sessionActor ! SessionActor.Recall(sanctuary)
}
case (CMT_INSTANTACTION, _, _) =>
if (session.zoningType == Zoning.Method.Quit) {
sessionActor ! SessionActor.SendResponse(
ChatMsg(CMT_QUIT, false, "", "You can't instant action while quitting.", None)
)
} else if (session.zoningType == Zoning.Method.InstantAction) {
sessionActor ! SessionActor.SendResponse(
ChatMsg(CMT_QUIT, false, "", "@noinstantaction_instantactionting", None)
)
} else if (session.zoningType == Zoning.Method.Recall) {
sessionActor ! SessionActor.SendResponse(
ChatMsg(
CMT_QUIT,
false,
"",
"You won't instant action. You already requested to recall to your sanctuary continent",
None
)
)
} else if (!session.player.isAlive || session.deadState != DeadState.Alive) {
if (session.player.isAlive) {
sessionActor ! SessionActor.SendResponse(
ChatMsg(CMT_QUIT, false, "", "@noinstantaction_deconstructing", None)
)
} else {
sessionActor ! SessionActor.SendResponse(
ChatMsg(CMT_QUIT, false, "", "@noinstantaction_dead", None)
)
}
} else if (session.player.VehicleSeated.nonEmpty) {
sessionActor ! SessionActor.SendResponse(
ChatMsg(CMT_QUIT, false, "", "@noinstantaction_invehicle", None)
)
} else {
sessionActor ! SessionActor.InstantAction()
}
case (CMT_QUIT, _, _) =>
if (session.zoningType == Zoning.Method.Quit) {
sessionActor ! SessionActor.SendResponse(ChatMsg(CMT_QUIT, false, "", "@noquit_quitting", None))
} else if (!session.player.isAlive || session.deadState != DeadState.Alive) {
if (session.player.isAlive) {
sessionActor ! SessionActor.SendResponse(
ChatMsg(CMT_QUIT, false, "", "@noquit_deconstructing", None)
)
} else {
sessionActor ! SessionActor.SendResponse(ChatMsg(CMT_QUIT, false, "", "@noquit_dead", None))
}
} else if (session.player.VehicleSeated.nonEmpty) {
sessionActor ! SessionActor.SendResponse(ChatMsg(CMT_QUIT, false, "", "@noquit_invehicle", None))
} else {
sessionActor ! SessionActor.Quit()
}
case (CMT_SUICIDE, _, _) =>
if (session.player.isAlive && session.deadState != DeadState.Release) {
sessionActor ! SessionActor.Suicide()
}
case (CMT_DESTROY, recipient, contents) =>
val guid = contents.toInt
session.zone.GUID(session.zone.Map.TerminalToSpawnPad.getOrElse(guid, guid)) match {
case Some(pad: VehicleSpawnPad) =>
pad.Actor ! VehicleSpawnControl.ProcessControl.Flush
case Some(turret: FacilityTurret) if turret.isUpgrading =>
WeaponTurrets.FinishUpgradingMannedTurret(turret, TurretUpgrade.None)
case _ =>
sessionActor ! SessionActor.SendResponse(
PacketCoding.CreateGamePacket(0, RequestDestroyMessage(PlanetSideGUID(guid))).packet
)
}
sessionActor ! SessionActor.SendResponse(message)
case (_, _, "!loc") =>
val continent = session.zone
val player = session.player
val loc =
s"zone=${continent.Id} pos=${player.Position.x},${player.Position.y},${player.Position.z}; ori=${player.Orientation.x},${player.Orientation.y},${player.Orientation.z}"
log.info(loc)
sessionActor ! SessionActor.SendResponse(message.copy(contents = loc))
case (_, _, contents) if contents.startsWith("!list") =>
val zone = contents.split(" ").lift(1) match {
case None =>
Some(session.zone)
case Some(id) =>
Zones.zones.get(id)
}
zone match {
case Some(zone) =>
sessionActor ! SessionActor.SendResponse(
ChatMsg(
CMT_GMOPEN,
message.wideContents,
"Server",
"\\#8Name (Faction) [ID] at PosX PosY PosZ",
message.note
)
)
(zone.LivePlayers ++ zone.Corpses)
.filter(_.CharId != session.player.CharId)
.sortBy(_.Name)
.foreach(player => {
sessionActor ! SessionActor.SendResponse(
ChatMsg(
CMT_GMOPEN,
message.wideContents,
"Server",
s"\\#7${player.Name} (${player.Faction}) [${player.CharId}] at ${player.Position.x.toInt} ${player.Position.y.toInt} ${player.Position.z.toInt}",
message.note
)
)
})
case None =>
sessionActor ! SessionActor.SendResponse(
ChatMsg(
CMT_GMOPEN,
message.wideContents,
"Server",
"Invalid zone ID",
message.note
)
)
}
case (_, _, contents) if session.admin && contents.startsWith("!kick") =>
val input = contents.split("\\s+").drop(1)
if (input.length > 0) {
val numRegex = raw"(\d+)".r
val id = input(0)
val determination: Player => Boolean = id match {
case numRegex(_) => _.CharId == id.toLong
case _ => _.Name.equals(id)
}
session.zone.LivePlayers
.find(determination)
.orElse(session.zone.Corpses.find(determination)) match {
case Some(player) =>
input.lift(1) match {
case Some(numRegex(time)) =>
sessionActor ! SessionActor.Kick(player, Some(time.toLong))
case _ =>
sessionActor ! SessionActor.Kick(player)
}
case None =>
sessionActor ! SessionActor.SendResponse(
ChatMsg(
CMT_GMOPEN,
message.wideContents,
"Server",
"Invalid player",
message.note
)
)
}
}
case (CMT_CAPTUREBASE, _, contents) if session.admin =>
val args = contents.split(" ").filter(_ != "")
val (faction, factionPos) = args.zipWithIndex
.map { case (faction, pos) => (faction.toLowerCase, pos) }
.flatMap {
case ("tr", pos) => Some(PlanetSideEmpire.TR, pos)
case ("nc", pos) => Some(PlanetSideEmpire.NC, pos)
case ("vs", pos) => Some(PlanetSideEmpire.VS, pos)
case ("none", pos) => Some(PlanetSideEmpire.NEUTRAL, pos)
case _ => None
}
.headOption match {
case Some((faction, pos)) => (faction, Some(pos))
case None => (session.player.Faction, None)
}
val (buildingsOption, buildingPos) = args.zipWithIndex.flatMap {
case (_, pos) if factionPos.isDefined && factionPos.get == pos => None
case ("all", pos) =>
Some(
Some(
session.zone.Buildings
.filter {
case (_, building) => building.CaptureTerminal.isDefined
}
.values
.toSeq
),
Some(pos)
)
case (name, pos) =>
session.zone.Buildings.find {
case (_, building) => name.equalsIgnoreCase(building.Name) && building.CaptureTerminal.isDefined
} match {
case Some((_, building)) => Some(Some(Seq(building)), Some(pos))
case None =>
try {
// check if we have a timer
name.toInt
None
} catch {
case _: Throwable =>
Some(None, Some(pos))
}
}
}.headOption match {
case Some((buildings, pos)) => (buildings, pos)
case None => (None, None)
}
val (timerOption, timerPos) = args.zipWithIndex.flatMap {
case (_, pos)
if factionPos.isDefined && factionPos.get == pos || buildingPos.isDefined && buildingPos.get == pos =>
None
case (timer, pos) =>
try {
val t = timer.toInt // TODO what is the timer format supposed to be?
Some(Some(t), Some(pos))
} catch {
case _: Throwable =>
Some(None, Some(pos))
}
}.headOption match {
case Some((timer, posOption)) => (timer, posOption)
case None => (None, None)
}
(factionPos, buildingPos, timerPos, buildingsOption, timerOption) match {
case // [[<empire>|none [<timer>]]
(Some(0), None, Some(1), None, Some(_)) | (Some(0), None, None, None, None) |
(None, None, None, None, None) |
// [<building name> [<empire>|none [timer]]]
(None | Some(1), Some(0), None, Some(_), None) | (Some(1), Some(0), Some(2), Some(_), Some(_)) |
// [all [<empire>|none]]
(Some(1) | None, Some(0), None, Some(_), None) =>
val buildings = buildingsOption.getOrElse(
session.zone.Buildings
.filter {
case (_, building) =>
building.PlayersInSOI.exists { soiPlayer =>
session.player.CharId == soiPlayer.CharId
}
}
.map { case (_, building) => building }
)
buildings foreach { building =>
// TODO implement timer
building.Faction = faction
session.zone.LocalEvents ! LocalServiceMessage(
session.zone.Id,
LocalAction.SetEmpire(building.GUID, faction)
)
}
case (_, Some(0), _, None, _) =>
sessionActor ! SessionActor.SendResponse(
ChatMsg(
UNK_229,
true,
"",
s"\\#FF4040ERROR - \'${args(0)}\' is not a valid building name.",
None
)
)
case (Some(0), _, Some(1), _, None) | (Some(1), Some(0), Some(2), _, None) =>
sessionActor ! SessionActor.SendResponse(
ChatMsg(
UNK_229,
true,
"",
s"\\#FF4040ERROR - \'${args(timerPos.get)}\' is not a valid timer value.",
None
)
)
case _ =>
sessionActor ! SessionActor.SendResponse(
ChatMsg(
UNK_229,
true,
"",
"usage: /capturebase [[<empire>|none [<timer>]] | [<building name> [<empire>|none [timer]]] | [all [<empire>|none]]",
None
)
)
}
case (CMT_GMBROADCAST | CMT_GMBROADCAST_NC | CMT_GMBROADCAST_VS | CMT_GMBROADCAST_TR, _, _)
if session.admin =>
chatService ! ChatService.Message(
session,
message.copy(recipient = session.player.Name),
ChatChannel.Default()
)
case (CMT_GMTELL, _, _) if session.admin =>
chatService ! ChatService.Message(
session,
message.copy(recipient = session.player.Name),
ChatChannel.Default()
)
case (CMT_GMBROADCASTPOPUP, _, _) if session.admin =>
chatService ! ChatService.Message(
session,
message.copy(recipient = session.player.Name),
ChatChannel.Default()
)
case (_, _, contents) if contents.startsWith("!whitetext ") && session.admin =>
chatService ! ChatService.Message(
session,
ChatMsg(UNK_227, true, "", contents.replace("!whitetext ", ""), None),
ChatChannel.Default()
)
case (_, "tr", contents) =>
sessionActor ! SessionActor.SendResponse(
ZonePopulationUpdateMessage(4, 414, 138, contents.toInt, 138, contents.toInt / 2, 138, 0, 138, 0)
)
case (_, "nc", contents) =>
sessionActor ! SessionActor.SendResponse(
ZonePopulationUpdateMessage(4, 414, 138, 0, 138, contents.toInt, 138, contents.toInt / 3, 138, 0)
)
case (_, "vs", contents) =>
sessionActor ! SessionActor.SendResponse(
ZonePopulationUpdateMessage(4, 414, 138, contents.toInt * 2, 138, 0, 138, contents.toInt, 138, 0)
)
case (_, "bo", contents) =>
sessionActor ! SessionActor.SendResponse(
ZonePopulationUpdateMessage(4, 414, 138, 0, 138, 0, 138, 0, 138, contents.toInt)
)
case (_, _, contents) if contents.startsWith("!ntu") && session.admin =>
session.zone.Buildings.values.foreach(building =>
building.Amenities.foreach(amenity =>
amenity.Definition match {
case GlobalDefinitions.resource_silo =>
val r = new scala.util.Random
val silo = amenity.asInstanceOf[ResourceSilo]
val ntu: Int = 900 + r.nextInt(100) - silo.ChargeLevel
// val ntu: Int = 0 + r.nextInt(100) - silo.ChargeLevel
silo.Actor ! ResourceSilo.UpdateChargeLevel(ntu)
case _ => ;
}
)
)
case (CMT_OPEN, _, _) if !session.player.silenced =>
chatService ! ChatService.Message(
session,
message.copy(recipient = session.player.Name),
ChatChannel.Default()
)
case (CMT_VOICE, _, _) =>
chatService ! ChatService.Message(
session,
message.copy(recipient = session.player.Name),
ChatChannel.Default()
)
case (CMT_TELL, _, _) if !session.player.silenced =>
chatService ! ChatService.Message(
session,
message,
ChatChannel.Default()
)
case (CMT_BROADCAST, _, _) if !session.player.silenced =>
chatService ! ChatService.Message(
session,
message.copy(recipient = session.player.Name),
ChatChannel.Default()
)
case (CMT_PLATOON, _, _) if !session.player.silenced =>
chatService ! ChatService.Message(
session,
message.copy(recipient = session.player.Name),
ChatChannel.Default()
)
case (CMT_COMMAND, _, _) if session.admin =>
chatService ! ChatService.Message(
session,
message.copy(recipient = session.player.Name),
ChatChannel.Default()
)
case (CMT_NOTE, _, _) =>
chatService ! ChatService.Message(session, message, ChatChannel.Default())
case (CMT_SILENCE, _, _) if session.admin =>
chatService ! ChatService.Message(session, message, ChatChannel.Default())
case (CMT_SQUAD, _, _) =>
channels.foreach {
case channel: ChatChannel.Squad =>
chatService ! ChatService.Message(session, message.copy(recipient = session.player.Name), channel)
case _ =>
}
case (
CMT_WHO | CMT_WHO_CSR | CMT_WHO_CR | CMT_WHO_PLATOONLEADERS | CMT_WHO_SQUADLEADERS | CMT_WHO_TEAMS,
_,
_
) =>
val players = session.zone.Players
val popTR = players.count(_.faction == PlanetSideEmpire.TR)
val popNC = players.count(_.faction == PlanetSideEmpire.NC)
val popVS = players.count(_.faction == PlanetSideEmpire.VS)
val contName = session.zone.Map.Name
sessionActor ! SessionActor.SendResponse(
ChatMsg(ChatMessageType.CMT_WHO, true, "", "That command doesn't work for now, but : ", None)
)
sessionActor ! SessionActor.SendResponse(
ChatMsg(ChatMessageType.CMT_WHO, true, "", "NC online : " + popNC + " on " + contName, None)
)
sessionActor ! SessionActor.SendResponse(
ChatMsg(ChatMessageType.CMT_WHO, true, "", "TR online : " + popTR + " on " + contName, None)
)
sessionActor ! SessionActor.SendResponse(
ChatMsg(ChatMessageType.CMT_WHO, true, "", "VS online : " + popVS + " on " + contName, None)
)
case (CMT_ZONE, _, contents) if session.admin =>
val buffer = contents.toLowerCase.split("\\s+")
val (zone, gate, list) = (buffer.lift(0), buffer.lift(1)) match {
case (Some("-list"), None) =>
(None, None, true)
case (Some(zoneId), Some("-list")) =>
(PointOfInterest.get(zoneId), None, true)
case (Some(zoneId), gateId) =>
val zone = PointOfInterest.get(zoneId)
val gate = (zone, gateId) match {
case (Some(zone), Some(gateId)) => PointOfInterest.getWarpgate(zone, gateId)
case (Some(zone), None) => Some(PointOfInterest.selectRandom(zone))
case _ => None
}
(zone, gate, false)
case _ =>
(None, None, false)
}
(zone, gate, list) match {
case (None, None, true) =>
sessionActor ! SessionActor.SendResponse(ChatMsg(UNK_229, true, "", PointOfInterest.list, None))
case (Some(zone), None, true) =>
sessionActor ! SessionActor.SendResponse(
ChatMsg(UNK_229, true, "", PointOfInterest.listWarpgates(zone), None)
)
case (Some(zone), Some(gate), false) =>
sessionActor ! SessionActor.SetZone(zone.zonename, gate)
case (_, None, false) =>
sessionActor ! SessionActor.SendResponse(
ChatMsg(UNK_229, true, "", "Gate id not defined (use '/zone <zone> -list')", None)
)
case (_, _, _) if buffer.isEmpty || buffer(0).equals("-help") =>
sessionActor ! SessionActor.SendResponse(
ChatMsg(UNK_229, true, "", "usage: /zone <zone> [gatename] | [-list]", None)
)
}
case (CMT_WARP, _, contents) if session.admin =>
val buffer = contents.toLowerCase.split("\\s+")
val (coordinates, waypoint) = (buffer.lift(0), buffer.lift(1), buffer.lift(2)) match {
case (Some(x), Some(y), Some(z)) => (Some(x, y, z), None)
case (Some("to"), Some(character), None) => (None, None) // TODO not implemented
case (Some("near"), Some(objectName), None) => (None, None) // TODO not implemented
case (Some(waypoint), None, None) => (None, Some(waypoint))
case _ => (None, None)
}
(coordinates, waypoint) match {
case (Some((x, y, z)), None) if List(x, y, z).forall { str =>
val coordinate = str.toFloatOption
coordinate.isDefined && coordinate.get >= 0 && coordinate.get <= 8191
} =>
sessionActor ! SessionActor.SetPosition(Vector3(x.toFloat, y.toFloat, z.toFloat))
case (None, Some(waypoint)) if waypoint != "-help" =>
PointOfInterest.getWarpLocation(session.zone.Id, waypoint) match {
case Some(location) => sessionActor ! SessionActor.SetPosition(location)
case None =>
sessionActor ! SessionActor.SendResponse(
ChatMsg(UNK_229, true, "", s"unknown location '$waypoint", None)
)
}
case _ =>
sessionActor ! SessionActor.SendResponse(
ChatMsg(
UNK_229,
true,
"",
s"usage: /warp <x><y><z> OR /warp to <character> OR /warp near <object> OR /warp above <object> OR /warp waypoint",
None
)
)
}
case _ =>
log.info("unhandled chat message $message")
}
case (None, _) | (_, None) =>
log.error("failed to handle message because dependencies are missing")
}
this
case IncomingMessage(fromSession, message, channel) =>
(session) match {
case Some(session) =>
message.messageType match {
case CMT_TELL | U_CMT_TELLFROM | CMT_BROADCAST | CMT_SQUAD | CMT_PLATOON | CMT_COMMAND | UNK_45 | UNK_71 |
CMT_NOTE | CMT_GMBROADCAST | CMT_GMBROADCAST_NC | CMT_GMBROADCAST_TR | CMT_GMBROADCAST_VS |
CMT_GMBROADCASTPOPUP | CMT_GMTELL | U_CMT_GMTELLFROM | UNK_227 =>
sessionActor ! SessionActor.SendResponse(message)
case CMT_OPEN =>
if (
session.zone == fromSession.zone &&
Vector3.Distance(session.player.Position, fromSession.player.Position) < 25 &&
session.player.Faction == fromSession.player.Faction
) {
sessionActor ! SessionActor.SendResponse(message)
}
case CMT_VOICE =>
if (
session.zone == fromSession.zone &&
Vector3.Distance(session.player.Position, fromSession.player.Position) < 25
) {
sessionActor ! SessionActor.SendResponse(message)
}
case CMT_SILENCE =>
val args = message.contents.split(" ")
val (name, time) = (args.lift(0), args.lift(1)) match {
case (Some(name), _) if name != session.player.Name =>
log.error("received silence message for other player")
(None, None)
case (Some(name), None) => (Some(name), Some(5))
case (Some(name), Some(time)) if time.toIntOption.isDefined => (Some(name), Some(time.toInt))
case _ => (None, None)
}
(name, time) match {
case (Some(_), Some(time)) =>
if (session.player.silenced) {
sessionActor ! SessionActor.SetSilenced(false)
sessionActor ! SessionActor.SendResponse(
ChatMsg(ChatMessageType.UNK_71, true, "", "@silence_off", None)
)
if (!silenceTimer.isCancelled) silenceTimer.cancel()
} else {
sessionActor ! SessionActor.SetSilenced(true)
sessionActor ! SessionActor.SendResponse(
ChatMsg(ChatMessageType.UNK_71, true, "", "@silence_on", None)
)
silenceTimer = context.system.scheduler.scheduleOnce(
time minutes,
() => {
sessionActor ! SessionActor.SetSilenced(false)
sessionActor ! SessionActor.SendResponse(
ChatMsg(ChatMessageType.UNK_71, true, "", "@silence_timeout", None)
)
}
)
}
case (name, time) =>
log.error(s"bad silence args $name $time")
}
case _ =>
log.error(s"unexpected messageType $message")
}
case None =>
log.error("failed to handle incoming message because dependencies are missing")
}
this
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,358 @@
package net.psforever.login
import java.security.SecureRandom
import akka.actor.MDCContextAware.Implicits._
import akka.actor.{Actor, ActorRef, MDCContextAware}
import net.psforever.crypto.CryptoInterface
import net.psforever.crypto.CryptoInterface.CryptoStateWithMAC
import net.psforever.packet._
import net.psforever.packet.control._
import net.psforever.packet.crypto._
import net.psforever.packet.game.PingMsg
import org.log4s.MDC
import scodec.Attempt.{Failure, Successful}
import scodec.bits._
sealed trait CryptoSessionAPI
final case class DropCryptoSession() extends CryptoSessionAPI
/**
* Actor that stores crypto state for a connection, appropriately encrypts and decrypts packets,
* and passes packets along to the next hop once processed.
*/
class CryptoSessionActor extends Actor with MDCContextAware {
private[this] val log = org.log4s.getLogger
var sessionId: Long = 0
var leftRef: ActorRef = ActorRef.noSender
var rightRef: ActorRef = ActorRef.noSender
var cryptoDHState: Option[CryptoInterface.CryptoDHState] = None
var cryptoState: Option[CryptoInterface.CryptoStateWithMAC] = None
val random = new SecureRandom()
// crypto handshake state
var serverChallenge = ByteVector.empty
var serverChallengeResult = ByteVector.empty
var serverMACBuffer = ByteVector.empty
var clientPublicKey = ByteVector.empty
var clientChallenge = ByteVector.empty
var clientChallengeResult = ByteVector.empty
var clientNonce: Long = 0
var serverNonce: Long = 0
// Don't leak crypto object memory even on an exception
override def postStop() = {
cleanupCrypto()
}
def receive = Initializing
def Initializing: Receive = {
case HelloFriend(sharedSessionId, pipe) =>
import MDCContextAware.Implicits._
this.sessionId = sharedSessionId
leftRef = sender()
if (pipe.hasNext) {
rightRef = pipe.next // who ever we send to has to send something back to us
rightRef !> HelloFriend(sessionId, pipe)
} else {
rightRef = sender()
}
log.trace(s"Left sender ${leftRef.path.name}")
context.become(NewClient)
case default =>
log.error("Unknown message " + default)
context.stop(self)
}
def NewClient: Receive = {
case RawPacket(msg) =>
PacketCoding.UnmarshalPacket(msg) match {
case Successful(p) =>
log.trace("Initializing -> NewClient")
p match {
case ControlPacket(_, ClientStart(nonce)) =>
clientNonce = nonce
serverNonce = Math.abs(random.nextInt())
sendResponse(PacketCoding.CreateControlPacket(ServerStart(nonce, serverNonce)))
log.trace(s"ClientStart($nonce), $serverNonce")
context.become(CryptoExchange)
case _ =>
log.error(s"Unexpected packet type $p in state NewClient")
}
case Failure(_) =>
// There is a special case where no crypto is being used.
// The only packet coming through looks like PingMsg. This is a hardcoded
// feature of the client @ 0x005FD618
PacketCoding.DecodePacket(msg) match {
case Successful(packet) =>
packet match {
case ping @ PingMsg(_, _) =>
// reflect the packet back to the sender
sendResponse(ping)
case _ =>
log.error(s"Unexpected non-crypto packet type $packet in state NewClient")
}
case Failure(e) =>
log.error("Could not decode packet: " + e + s" in state NewClient")
}
}
case default =>
log.error(s"Invalid message '$default' received in state NewClient")
}
def CryptoExchange: Receive = {
case RawPacket(msg) =>
PacketCoding.UnmarshalPacket(msg, CryptoPacketOpcode.ClientChallengeXchg) match {
case Failure(e) =>
log.error("Could not decode packet in state CryptoExchange: " + e)
case Successful(pkt) =>
log.trace("NewClient -> CryptoExchange")
pkt match {
case CryptoPacket(seq, ClientChallengeXchg(time, challenge, p, g)) =>
cryptoDHState = Some(new CryptoInterface.CryptoDHState())
val dh = cryptoDHState.get
// initialize our crypto state from the client's P and G
dh.start(p, g)
// save the client challenge
clientChallenge = ServerChallengeXchg.getCompleteChallenge(time, challenge)
// save the packet we got for a MAC check later. drop the first 3 bytes
serverMACBuffer ++= msg.drop(3)
val serverTime = System.currentTimeMillis() / 1000L
val randomChallenge = getRandBytes(0xc)
// store the complete server challenge for later
serverChallenge = ServerChallengeXchg.getCompleteChallenge(serverTime, randomChallenge)
val packet =
PacketCoding.CreateCryptoPacket(seq, ServerChallengeXchg(serverTime, randomChallenge, dh.getPublicKey))
val sentPacket = sendResponse(packet)
// save the sent packet a MAC check
serverMACBuffer ++= sentPacket.drop(3)
context.become(CryptoSetupFinishing)
case _ =>
log.error(s"Unexpected packet type $pkt in state CryptoExchange")
}
}
case default =>
log.error(s"Invalid message '$default' received in state CryptoExchange")
}
def CryptoSetupFinishing: Receive = {
case RawPacket(msg) =>
PacketCoding.UnmarshalPacket(msg, CryptoPacketOpcode.ClientFinished) match {
case Failure(e) => log.error("Could not decode packet in state CryptoSetupFinishing: " + e)
case Successful(p) =>
log.trace("CryptoExchange -> CryptoSetupFinishing")
p match {
case CryptoPacket(seq, ClientFinished(clientPubKey, clientChalResult)) =>
clientPublicKey = clientPubKey
clientChallengeResult = clientChalResult
// save the packet we got for a MAC check later
serverMACBuffer ++= msg.drop(3)
val dh = cryptoDHState.get
val agreedValue = dh.agree(clientPublicKey)
// we are now done with the DH crypto object
dh.close
/*println("Agreed: " + agreedValue)
println(s"Client challenge: $clientChallenge")*/
val agreedMessage = ByteVector("master secret".getBytes) ++ clientChallenge ++
hex"00000000" ++ serverChallenge ++ hex"00000000"
//println("In message: " + agreedMessage)
val masterSecret = CryptoInterface.MD5MAC(agreedValue, agreedMessage, 20)
//println("Master secret: " + masterSecret)
serverChallengeResult = CryptoInterface.MD5MAC(
masterSecret,
ByteVector("server finished".getBytes) ++ serverMACBuffer ++ hex"01",
0xc
)
// val clientChallengeResultCheck = CryptoInterface.MD5MAC(masterSecret,
// ByteVector("client finished".getBytes) ++ serverMACBuffer ++ hex"01" ++ clientChallengeResult ++ hex"01",
// 0xc)
// println("Check result: " + CryptoInterface.verifyMAC(clientChallenge, clientChallengeResult))
val decExpansion = ByteVector("client expansion".getBytes) ++ hex"0000" ++ serverChallenge ++
hex"00000000" ++ clientChallenge ++ hex"00000000"
val encExpansion = ByteVector("server expansion".getBytes) ++ hex"0000" ++ serverChallenge ++
hex"00000000" ++ clientChallenge ++ hex"00000000"
/*println("DecExpansion: " + decExpansion)
println("EncExpansion: " + encExpansion)*/
// expand the encryption and decryption keys
// The first 20 bytes are for RC5, and the next 16 are for the MAC'ing keys
val expandedDecKey =
CryptoInterface.MD5MAC(masterSecret, decExpansion, 0x40) // this is what is visible in IDA
val expandedEncKey = CryptoInterface.MD5MAC(masterSecret, encExpansion, 0x40)
val decKey = expandedDecKey.take(20)
val encKey = expandedEncKey.take(20)
val decMACKey = expandedDecKey.drop(20).take(16)
val encMACKey = expandedEncKey.drop(20).take(16)
/*println("**** DecKey: " + decKey)
println("**** EncKey: " + encKey)
println("**** DecMacKey: " + decMACKey)
println("**** EncMacKey: " + encMACKey)*/
// spin up our encryption program
cryptoState = Some(new CryptoStateWithMAC(decKey, encKey, decMACKey, encMACKey))
val packet = PacketCoding.CreateCryptoPacket(seq, ServerFinished(serverChallengeResult))
sendResponse(packet)
context.become(Established)
case default => failWithError(s"Unexpected packet type $default in state CryptoSetupFinished")
}
}
case default => failWithError(s"Invalid message '$default' received in state CryptoSetupFinished")
}
def Established: Receive = {
//same as having received ad hoc hexadecimal
case RawPacket(msg) =>
if (sender() == rightRef) {
val packet = PacketCoding.encryptPacket(cryptoState.get, 0, msg).require
sendResponse(packet)
} else { //from network-side
PacketCoding.UnmarshalPacket(msg) match {
case Successful(p) =>
p match {
case encPacket @ EncryptedPacket(_ /*seq*/, _) =>
PacketCoding.decryptPacketData(cryptoState.get, encPacket) match {
case Successful(packet) =>
MDC("sessionId") = sessionId.toString
rightRef !> RawPacket(packet)
case Failure(e) =>
log.error("Failed to decode encrypted packet: " + e)
}
case default =>
failWithError(s"Unexpected packet type $default in state Established")
}
case Failure(e) =>
log.error("Could not decode raw packet: " + e)
}
}
//message to self?
case api: CryptoSessionAPI =>
api match {
case DropCryptoSession() =>
handleEstablishedPacket(
sender(),
PacketCoding.CreateControlPacket(TeardownConnection(clientNonce))
)
}
//echo the session router? isn't that normally the leftRef?
case sessionAPI: SessionRouterAPI =>
leftRef !> sessionAPI
//error
case default =>
failWithError(s"Invalid message '$default' received in state Established")
}
def failWithError(error: String) = {
log.error(error)
}
def cleanupCrypto() = {
if (cryptoDHState.isDefined) {
cryptoDHState.get.close
cryptoDHState = None
}
if (cryptoState.isDefined) {
cryptoState.get.close
cryptoState = None
}
}
def resetState(): Unit = {
context.become(receive)
// reset the crypto primitives
cleanupCrypto()
serverChallenge = ByteVector.empty
serverChallengeResult = ByteVector.empty
serverMACBuffer = ByteVector.empty
clientPublicKey = ByteVector.empty
clientChallenge = ByteVector.empty
clientChallengeResult = ByteVector.empty
}
def handleEstablishedPacket(from: ActorRef, cont: PlanetSidePacketContainer): Unit = {
//we are processing a packet that we decrypted
if (from == self) { //to WSA, LSA, etc.
rightRef !> cont
} else if (from == rightRef) { //processing a completed packet from the right; to network-side
PacketCoding.getPacketDataForEncryption(cont) match {
case Successful((seq, data)) =>
val packet = PacketCoding.encryptPacket(cryptoState.get, seq, data).require
sendResponse(packet)
case Failure(ex) =>
log.error(s"$ex")
}
} else {
log.error(s"Invalid sender when handling a message in Established $from")
}
}
def sendResponse(cont: PlanetSidePacketContainer): ByteVector = {
log.trace("CRYPTO SEND: " + cont)
val pkt = PacketCoding.MarshalPacket(cont)
pkt match {
case Failure(_) =>
log.error(s"Failed to marshal packet ${cont.getClass.getName} when sending response")
ByteVector.empty
case Successful(v) =>
val bytes = v.toByteVector
MDC("sessionId") = sessionId.toString
leftRef !> ResponsePacket(bytes)
bytes
}
}
def sendResponse(pkt: PlanetSideGamePacket): ByteVector = {
log.trace("CRYPTO SEND GAME: " + pkt)
val pktEncoded = PacketCoding.EncodePacket(pkt)
pktEncoded match {
case Failure(_) =>
log.error(s"Failed to encode packet ${pkt.getClass.getName} when sending response")
ByteVector.empty
case Successful(v) =>
val bytes = v.toByteVector
MDC("sessionId") = sessionId.toString
leftRef !> ResponsePacket(bytes)
bytes
}
}
def getRandBytes(amount: Int): ByteVector = {
val array = Array.ofDim[Byte](amount)
random.nextBytes(array)
ByteVector.view(array)
}
}

View file

@ -0,0 +1,319 @@
package net.psforever.login
import java.net.{InetAddress, InetSocketAddress}
import akka.actor.MDCContextAware.Implicits._
import akka.actor.{Actor, ActorRef, Cancellable, MDCContextAware}
import com.github.t3hnar.bcrypt._
import net.psforever.objects.{Account, Default}
import net.psforever.packet.control._
import net.psforever.packet.game.LoginRespMessage.{LoginError, StationError, StationSubscriptionStatus}
import net.psforever.packet.game._
import net.psforever.packet.{PlanetSideGamePacket, _}
import net.psforever.persistence
import net.psforever.types.PlanetSideEmpire
import net.psforever.util.Config
import net.psforever.util.Database._
import org.log4s.MDC
import scodec.bits._
import services.ServiceManager
import services.ServiceManager.Lookup
import services.account.{ReceiveIPAddress, RetrieveIPAddress, StoreAccountData}
import scala.concurrent.Future
import scala.concurrent.duration._
import scala.util.{Failure, Success}
class LoginSessionActor extends Actor with MDCContextAware {
private[this] val log = org.log4s.getLogger
import scala.concurrent.ExecutionContext.Implicits.global
private case class UpdateServerList()
val usernameRegex = """[A-Za-z0-9]{3,}""".r
var sessionId: Long = 0
var leftRef: ActorRef = ActorRef.noSender
var rightRef: ActorRef = ActorRef.noSender
var accountIntermediary: ActorRef = ActorRef.noSender
var updateServerListTask: Cancellable = Default.Cancellable
var ipAddress: String = ""
var hostName: String = ""
var canonicalHostName: String = ""
var port: Int = 0
val serverName = Config.app.world.serverName
val publicAddress = new InetSocketAddress(InetAddress.getByName(Config.app.public), Config.app.world.port)
// Reference: https://stackoverflow.com/a/50470009
private val numBcryptPasses = 10
override def postStop() = {
if (updateServerListTask != null)
updateServerListTask.cancel()
}
def receive = Initializing
def Initializing: Receive = {
case HelloFriend(aSessionId, pipe) =>
this.sessionId = aSessionId
leftRef = sender()
if (pipe.hasNext) {
rightRef = pipe.next
rightRef !> HelloFriend(aSessionId, pipe)
} else {
rightRef = sender()
}
context.become(Started)
ServiceManager.serviceManager ! Lookup("accountIntermediary")
case _ =>
log.error("Unknown message")
context.stop(self)
}
def Started: Receive = {
case ServiceManager.LookupResult("accountIntermediary", endpoint) =>
accountIntermediary = endpoint
case ReceiveIPAddress(address) =>
ipAddress = address.Address
hostName = address.HostName
canonicalHostName = address.CanonicalHostName
port = address.Port
case UpdateServerList() =>
updateServerList()
case ControlPacket(_, ctrl) =>
handleControlPkt(ctrl)
case GamePacket(_, _, game) =>
handleGamePkt(game)
case default => failWithError(s"Invalid packet class received: $default")
}
def handleControlPkt(pkt: PlanetSideControlPacket) = {
pkt match {
/// TODO: figure out what this is what what it does for the PS client
/// I believe it has something to do with reliable packet transmission and resending
case sync @ ControlSync(diff, _, _, _, _, _, fa, fb) =>
log.trace(s"SYNC: $sync")
val serverTick = Math.abs(System.nanoTime().toInt) // limit the size to prevent encoding error
sendResponse(PacketCoding.CreateControlPacket(ControlSyncResp(diff, serverTick, fa, fb, fb, fa)))
case TeardownConnection(_) =>
sendResponse(DropSession(sessionId, "client requested session termination"))
case default =>
log.error(s"Unhandled ControlPacket $default")
}
}
def handleGamePkt(pkt: PlanetSideGamePacket) =
pkt match {
case LoginMessage(majorVersion, minorVersion, buildDate, username, password, token, revision) =>
// TODO: prevent multiple LoginMessages from being processed in a row!! We need a state machine
val clientVersion = s"Client Version: $majorVersion.$minorVersion.$revision, $buildDate"
accountIntermediary ! RetrieveIPAddress(sessionId)
if (token.isDefined)
log.info(s"New login UN:$username Token:${token.get}. $clientVersion")
else {
// log.info(s"New login UN:$username PW:$password. $clientVersion")
log.info(s"New login UN:$username. $clientVersion")
}
accountLogin(username, password.get)
case ConnectToWorldRequestMessage(name, _, _, _, _, _, _) =>
log.info(s"Connect to world request for '$name'")
val response = ConnectToWorldMessage(serverName, publicAddress.getAddress.getHostAddress, publicAddress.getPort)
sendResponse(PacketCoding.CreateGamePacket(0, response))
sendResponse(DropSession(sessionId, "user transferring to world"))
case _ =>
log.debug(s"Unhandled GamePacket $pkt")
}
def accountLogin(username: String, password: String): Unit = {
import ctx._
val newToken = this.generateToken()
val result = for {
// backwards compatibility: prefer exact match first, then try lowercase
accountsExact <- ctx.run(query[persistence.Account].filter(_.username == lift(username)))
accountsLower <- accountsExact.headOption match {
case None =>
ctx.run(query[persistence.Account].filter(_.username.toLowerCase == lift(username).toLowerCase))
case Some(_) =>
Future.successful(Seq())
}
accountOption <- accountsExact.headOption orElse accountsLower.headOption match {
case Some(account) => Future.successful(Some(account))
case None => {
Config.app.login.createMissingAccounts match {
case true =>
val passhash: String = password.bcrypt(numBcryptPasses)
ctx.run(
query[persistence.Account]
.insert(_.passhash -> lift(passhash), _.username -> lift(username))
.returningGenerated(_.id)
) flatMap { id => ctx.run(query[persistence.Account].filter(_.id == lift(id))) } map { accounts =>
Some(accounts.head)
}
case false =>
loginFailureResponse(username, newToken)
Future.successful(None)
}
}
}
login <- accountOption match {
case Some(account) =>
(account.inactive, password.isBcrypted(account.passhash)) match {
case (false, true) =>
accountIntermediary ! StoreAccountData(newToken, new Account(account.id, account.username, account.gm))
val future = ctx.run(
query[persistence.Login].insert(
_.accountId -> lift(account.id),
_.ipAddress -> lift(ipAddress),
_.canonicalHostname -> lift(canonicalHostName),
_.hostname -> lift(hostName),
_.port -> lift(port)
)
)
loginSuccessfulResponse(username, newToken)
updateServerListTask =
context.system.scheduler.scheduleWithFixedDelay(0 seconds, 2 seconds, self, UpdateServerList())
future
case (_, false) =>
loginPwdFailureResponse(username, newToken)
Future.successful(None)
case (true, _) =>
loginAccountFailureResponse(username, newToken)
Future.successful(None)
}
case None => Future.successful(None)
}
} yield login
result.onComplete {
case Success(_) =>
case Failure(e) => log.error(e.getMessage())
}
}
def loginSuccessfulResponse(username: String, newToken: String) = {
sendResponse(
PacketCoding.CreateGamePacket(
0,
LoginRespMessage(
newToken,
LoginError.Success,
StationError.AccountActive,
StationSubscriptionStatus.Active,
0,
username,
10001
)
)
)
}
def loginPwdFailureResponse(username: String, newToken: String) = {
log.info(s"Failed login to account $username")
sendResponse(
PacketCoding.CreateGamePacket(
0,
LoginRespMessage(
newToken,
LoginError.BadUsernameOrPassword,
StationError.AccountActive,
StationSubscriptionStatus.Active,
685276011,
username,
10001
)
)
)
}
def loginFailureResponse(username: String, newToken: String) = {
log.info("DB problem")
sendResponse(
PacketCoding.CreateGamePacket(
0,
LoginRespMessage(
newToken,
LoginError.unk1,
StationError.AccountActive,
StationSubscriptionStatus.Active,
685276011,
username,
10001
)
)
)
}
def loginAccountFailureResponse(username: String, newToken: String) = {
log.info(s"Account $username inactive")
sendResponse(
PacketCoding.CreateGamePacket(
0,
LoginRespMessage(
newToken,
LoginError.BadUsernameOrPassword,
StationError.AccountClosed,
StationSubscriptionStatus.Active,
685276011,
username,
10001
)
)
)
}
def generateToken() = {
val r = new scala.util.Random
val sb = new StringBuilder
for (_ <- 1 to 31) {
sb.append(r.nextPrintableChar)
}
sb.toString
}
def updateServerList() = {
val msg = VNLWorldStatusMessage(
"Welcome to PlanetSide! ",
Vector(
WorldInformation(
serverName,
WorldStatus.Up,
Config.app.world.serverType,
Vector(WorldConnectionInfo(publicAddress)),
PlanetSideEmpire.VS
)
)
)
sendResponse(PacketCoding.CreateGamePacket(0, msg))
}
def failWithError(error: String) = {
log.error(error)
//sendResponse(PacketCoding.CreateControlPacket(ConnectionClose()))
}
def sendResponse(cont: Any) = {
log.trace("LOGIN SEND: " + cont)
MDC("sessionId") = sessionId.toString
rightRef !> cont
}
def sendRawResponse(pkt: ByteVector) = {
log.trace("LOGIN SEND RAW: " + pkt)
MDC("sessionId") = sessionId.toString
rightRef !> RawPacket(pkt)
}
}

View file

@ -0,0 +1,484 @@
package net.psforever.login
import akka.actor.MDCContextAware.Implicits._
import akka.actor.{Actor, ActorRef, Cancellable, MDCContextAware}
import net.psforever.objects.Default
import net.psforever.packet._
import net.psforever.packet.control.{HandleGamePacket, _}
import org.log4s.MDC
import scodec.Attempt.{Failure, Successful}
import scodec.bits._
import scala.annotation.tailrec
import scala.collection.mutable
import scala.collection.mutable.ArrayBuffer
import scala.concurrent.duration._
/**
* In between the network side and the higher functioning side of the simulation:
* accept packets and transform them into a sequence of data (encoding), and
* accept a sequence of data and transform it into s packet (decoding).<br>
* <br>
* Following the standardization of the `SessionRouter` pipeline, the throughput of this `Actor` has directionality.
* The "network," where the encoded data comes and goes, is assumed to be `leftRef`.
* The "simulation", where the decoded packets come and go, is assumed to be `rightRef`.
* `rightRef` can accept a sequence that looks like encoded data but it will merely pass out the same sequence.
* Likewise, `leftRef` accepts decoded packets but merely ejects the same packets without doing any work on them.
* The former functionality is anticipated.
* The latter functionality is deprecated.<br>
* <br>
* Encoded data leaving the `Actor` (`leftRef`) is limited by an upper bound capacity.
* Sequences can not be larger than that bound or else they will be dropped.
* This maximum transmission unit (MTU) is used to divide the encoded sequence into chunks of encoded data,
* re-packaged into nested `ControlPacket` units, and each unit encoded.
* The outer packaging is numerically consistent with a `subslot` that starts counting once the simulation starts.
* The client is very specific about the `subslot` number and will reject out-of-order packets.
* It resets to 0 each time this `Actor` starts up and the client reflects this functionality.
*/
class PacketCodingActor extends Actor with MDCContextAware {
private var sessionId: Long = 0
private var subslotOutbound: Int = 0
private var subslotInbound: Int = 0
private var leftRef: ActorRef = ActorRef.noSender
private var rightRef: ActorRef = ActorRef.noSender
private[this] val log = org.log4s.getLogger
/*
Since the client can indicate missing packets when sending SlottedMetaPackets we should keep a history of them to resend to the client when requested with a RelatedA packet
Since the subslot counter can wrap around, we need to use a LinkedHashMap to maintain the order packets are inserted, then we can drop older entries as required
For example when a RelatedB packet arrives we can remove any entries to the left of the received ones without risking removing newer entries if the subslot counter wraps around back to 0
*/
private var slottedPacketLog: mutable.LinkedHashMap[Int, ByteVector] = mutable.LinkedHashMap()
// Due to the fact the client can send `RelatedA` packets out of order, we need to keep a buffer of which subslots arrived correctly, order them
// and then act accordingly to send the missing subslot packet after a specified timeout
private var relatedALog: ArrayBuffer[Int] = ArrayBuffer()
private var relatedABufferTimeout: Cancellable = Default.Cancellable
def AddSlottedPacketToLog(subslot: Int, packet: ByteVector): Unit = {
val log_limit = 500 // Number of SlottedMetaPackets to keep in history
if (slottedPacketLog.size > log_limit) {
slottedPacketLog = slottedPacketLog.drop(slottedPacketLog.size - log_limit)
}
slottedPacketLog { subslot } = packet
}
override def postStop() = {
subslotOutbound = 0 //in case this `Actor` restarts
super.postStop()
}
def receive = Initializing
def Initializing: Receive = {
case HelloFriend(sharedSessionId, pipe) =>
import MDCContextAware.Implicits._
this.sessionId = sharedSessionId
leftRef = sender()
if (pipe.hasNext) {
rightRef = pipe.next
rightRef !> HelloFriend(sessionId, pipe)
} else {
rightRef = sender()
}
log.trace(s"Left sender ${leftRef.path.name}")
context.become(Established)
case default =>
log.error("Unknown message " + default)
context.stop(self)
}
def Established: Receive = {
case PacketCodingActor.SubslotResend() => {
log.trace(s"Subslot resend timeout reached, session: ${sessionId}")
relatedABufferTimeout.cancel()
log.trace(s"Client indicated successful subslots ${relatedALog.sortBy(x => x).mkString(" ")}")
// If a non-contiguous range of RelatedA packets were received we may need to send multiple missing packets, thus split the array into contiguous ranges
val sorted_log = relatedALog.sortBy(x => x)
val split_logs: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer[ArrayBuffer[Int]]()
var curr: ArrayBuffer[Int] = ArrayBuffer()
for (i <- 0 to sorted_log.size - 1) {
if (i == 0 || (sorted_log(i) != sorted_log(i - 1) + 1)) {
curr = new ArrayBuffer()
split_logs.append(curr)
}
curr.append(sorted_log(i))
}
if (split_logs.size > 1) log.trace(s"Split successful subslots into ${split_logs.size} contiguous chunks")
for (range <- split_logs) {
log.trace(s"Processing chunk ${range.mkString(" ")}")
val first_accepted_subslot = range.min
val missing_subslot = first_accepted_subslot - 1
slottedPacketLog.get(missing_subslot) match {
case Some(packet: ByteVector) =>
log.info(s"Resending packet with subslot: $missing_subslot to session: ${sessionId}")
sendResponseLeft(packet)
case None =>
log.error(s"Couldn't find packet with subslot: ${missing_subslot} to resend to session ${sessionId}.")
}
}
relatedALog.clear()
}
case RawPacket(msg) =>
if (sender == rightRef) { //from LSA, WSA, etc., to network - encode
mtuLimit(msg)
} else { //from network, to LSA, WSA, etc. - decode
UnmarshalInnerPacket(msg, "a packet")
}
//known elevated packet type
case ctrl @ ControlPacket(_, packet) =>
if (sender == rightRef) { //from LSA, WSA, to network - encode
PacketCoding.EncodePacket(packet) match {
case Successful(data) =>
mtuLimit(data.toByteVector)
case Failure(ex) =>
log.error(s"Failed to encode a ControlPacket: $ex")
}
} else { //deprecated; ControlPackets should not be coming from this direction
log.warn(s"DEPRECATED CONTROL PACKET SEND: $ctrl")
MDC("sessionId") = sessionId.toString
handlePacketContainer(ctrl) //sendResponseRight
}
//known elevated packet type
case game @ GamePacket(_, _, packet) =>
if (sender == rightRef) { //from LSA, WSA, etc., to network - encode
PacketCoding.EncodePacket(packet) match {
case Successful(data) =>
mtuLimit(data.toByteVector)
case Failure(ex) =>
log.error(s"Failed to encode a GamePacket: $ex")
}
} else { //deprecated; GamePackets should not be coming from this direction
log.warn(s"DEPRECATED GAME PACKET SEND: $game")
MDC("sessionId") = sessionId.toString
sendResponseRight(game)
}
//bundling packets into a SlottedMetaPacket0/MultiPacketEx
case msg @ MultiPacketBundle(list) =>
log.trace(s"BUNDLE PACKET REQUEST SEND, LEFT (always): $msg")
handleBundlePacket(list)
//etc
case msg =>
if (sender == rightRef) {
log.trace(s"BASE CASE PACKET SEND, LEFT: $msg")
MDC("sessionId") = sessionId.toString
leftRef !> msg
} else {
log.trace(s"BASE CASE PACKET SEND, RIGHT: $msg")
MDC("sessionId") = sessionId.toString
rightRef !> msg
}
}
/**
* Retrieve the current subslot number.
* Increment the `subslot` for the next time it is needed.
* @return a `16u` number starting at 0
*/
def Subslot: Int = {
if (subslotOutbound == 65536) { //TODO what is the actual wrap number?
subslotOutbound = 0
subslotOutbound
} else {
val curr = subslotOutbound
subslotOutbound += 1
curr
}
}
/**
* Check that an outbound packet is not too big to get stuck by the MTU.
* If it is larger than the MTU, divide it up and re-package the sections.
* Otherwise, send the data out like normal.
* @param msg the encoded packet data
*/
def mtuLimit(msg: ByteVector): Unit = {
if (msg.length > PacketCodingActor.MTU_LIMIT_BYTES) {
handleSplitPacket(PacketCoding.CreateControlPacket(HandleGamePacket(msg)))
} else {
sendResponseLeft(msg)
}
}
/**
* Transform a `ControlPacket` into `ByteVector` data for splitting.
* @param cont the original `ControlPacket`
*/
def handleSplitPacket(cont: ControlPacket): Unit = {
PacketCoding.getPacketDataForEncryption(cont) match {
case Successful((_, data)) =>
handleSplitPacket(data)
case Failure(ex) =>
log.error(s"$ex")
}
}
/**
* Accept `ByteVector` data, representing a `ControlPacket`, and split it into chunks.
* The chunks should not be blocked by the MTU.
* Send each chunk (towards the network) as it is converted.
* @param data `ByteVector` data to be split
*/
def handleSplitPacket(data: ByteVector): Unit = {
val lim = PacketCodingActor.MTU_LIMIT_BYTES - 4 //4 bytes is the base size of SlottedMetaPacket
data
.grouped(lim)
.foreach(bvec => {
val subslot = Subslot
PacketCoding.EncodePacket(SlottedMetaPacket(4, subslot, bvec)) match {
case Successful(bdata) =>
AddSlottedPacketToLog(subslot, bdata.toByteVector)
sendResponseLeft(bdata.toByteVector)
case f: Failure =>
log.error(s"$f")
}
})
}
/**
* Accept a `List` of packets and sequentially re-package the elements from the list into multiple container packets.<br>
* <br>
* The original packets are encoded then paired with their encoding lengths plus extra space to prefix the length.
* Encodings from these pairs are drawn from the list until into buckets that fit a maximum byte stream length.
* The size limitation on any bucket is the MTU limit.
* less by the base sizes of `MultiPacketEx` (2) and of `SlottedMetaPacket` (4).
* @param bundle the packets to be bundled
*/
def handleBundlePacket(bundle: List[PlanetSidePacket]): Unit = {
val packets: List[ByteVector] = recursiveEncode(bundle.iterator)
recursiveFillPacketBuckets(packets.iterator, PacketCodingActor.MTU_LIMIT_BYTES - 6)
.foreach(list => {
handleBundlePacket(list.toVector)
})
}
/**
* Accept a `Vector` of encoded packets and re-package them.
* The normal order is to package the elements of the vector into a `MultiPacketEx`.
* If the vector only has one element, it will get packaged by itself in a `SlottedMetaPacket`.
* If that one element risks being too big for the MTU, however, it will be handled off to be split.
* Splitting should preserve `Subslot` ordering with the rest of the bundling.
* @param vec a specific number of byte streams
*/
def handleBundlePacket(vec: Vector[ByteVector]): Unit = {
if (vec.size == 1) {
val elem = vec.head
if (elem.length > PacketCodingActor.MTU_LIMIT_BYTES - 4) {
handleSplitPacket(PacketCoding.CreateControlPacket(HandleGamePacket(elem)))
} else {
handleBundlePacket(elem)
}
} else {
PacketCoding.EncodePacket(MultiPacketEx(vec)) match {
case Successful(bdata) =>
handleBundlePacket(bdata.toByteVector)
case Failure(e) =>
log.warn(s"bundling failed on MultiPacketEx creation: - $e")
}
}
}
/**
* Accept `ByteVector` data and package it into a `SlottedMetaPacket`.
* Send it (towards the network) upon successful encoding.
* @param data an encoded packet
*/
def handleBundlePacket(data: ByteVector): Unit = {
val subslot = Subslot
PacketCoding.EncodePacket(SlottedMetaPacket(0, subslot, data)) match {
case Successful(bdata) =>
AddSlottedPacketToLog(subslot, bdata.toByteVector)
sendResponseLeft(bdata.toByteVector)
case Failure(e) =>
log.warn(s"bundling failed on SlottedMetaPacket creation: - $e")
}
}
/**
* Encoded sequence of data going towards the network.
* @param cont the data
*/
def sendResponseLeft(cont: ByteVector): Unit = {
log.trace("PACKET SEND, LEFT: " + cont)
MDC("sessionId") = sessionId.toString
leftRef !> RawPacket(cont)
}
/**
* Transform data into a container packet and re-submit that container to the process that handles the packet.
* @param data the packet data
* @param description an explanation of the input `data`
*/
def UnmarshalInnerPacket(data: ByteVector, description: String): Unit = {
PacketCoding.unmarshalPayload(0, data) match { //TODO is it safe for this to always be 0?
case Successful(packet) =>
handlePacketContainer(packet)
case Failure(ex) =>
log.info(s"Failed to unmarshal $description: $ex. Data : $data")
}
}
/**
* Sort and redirect a container packet bound for the server by type of contents.
* `GamePacket` objects can just onwards without issue.
* `ControlPacket` objects may need to be dequeued.
* All other container types are invalid.
* @param container the container packet
*/
def handlePacketContainer(container: PlanetSidePacketContainer): Unit = {
container match {
case _: GamePacket =>
sendResponseRight(container)
case ControlPacket(_, ctrlPkt) =>
handleControlPacket(container, ctrlPkt)
case default =>
log.warn(s"Invalid packet container class received: ${default.getClass.getName}") //do not spill contents in log
}
}
/**
* Process a control packet or determine that it does not need to be processed at this level.
* Primarily, if the packet is of a type that contains another packet that needs be be unmarshalled,
* that/those packet must be unwound.<br>
* <br>
* The subslot information is used to identify these nested packets after arriving at their destination,
* to establish order for sequential packets and relation between divided packets.
* @param container the original container packet
* @param packet the packet that was extracted from the container
*/
def handleControlPacket(container: PlanetSidePacketContainer, packet: PlanetSideControlPacket) = {
packet match {
case SlottedMetaPacket(slot, subslot, innerPacket) =>
subslotInbound = subslot
self.tell(PacketCoding.CreateControlPacket(RelatedB(slot, subslot)), rightRef) //will go to the network
UnmarshalInnerPacket(innerPacket, "the inner packet of a SlottedMetaPacket")
case MultiPacket(packets) =>
packets.foreach { UnmarshalInnerPacket(_, "the inner packet of a MultiPacket") }
case MultiPacketEx(packets) =>
packets.foreach { UnmarshalInnerPacket(_, "the inner packet of a MultiPacketEx") }
case RelatedA(slot, subslot) =>
log.trace(s"Client indicated a packet is missing prior to slot: $slot subslot: $subslot, session: ${sessionId}")
relatedALog += subslot
// (re)start the timeout period, if no more RelatedA packets are sent before the timeout period elapses the missing packet(s) will be resent
import scala.concurrent.ExecutionContext.Implicits.global
relatedABufferTimeout.cancel()
relatedABufferTimeout =
context.system.scheduler.scheduleOnce(100 milliseconds, self, PacketCodingActor.SubslotResend())
case RelatedB(slot, subslot) =>
log.trace(s"result $slot: subslot $subslot accepted, session: ${sessionId}")
// The client has indicated it's received up to a certain subslot, that means we can purge the log of any subslots prior to and including the confirmed subslot
// Find where this subslot is stored in the packet log (if at all) and drop anything to the left of it, including itself
if (relatedABufferTimeout.isCancelled || relatedABufferTimeout == Default.Cancellable) {
val pos = slottedPacketLog.keySet.toArray.indexOf(subslot)
if (pos != -1) {
slottedPacketLog = slottedPacketLog.drop(pos + 1)
log.trace(s"Subslots left in log: ${slottedPacketLog.keySet.toString()}")
}
}
case _ =>
sendResponseRight(container)
}
}
/**
* Decoded packet going towards the simulation.
* @param cont the packet
*/
def sendResponseRight(cont: PlanetSidePacketContainer): Unit = {
log.trace("PACKET SEND, RIGHT: " + cont)
MDC("sessionId") = sessionId.toString
rightRef !> cont
}
/**
* Accept a series of packets and transform it into a series of packet encodings.
* Packets that do not encode properly are simply excluded from the product.
* This is not treated as an error or exception; a warning will merely be logged.
* @param iter the `Iterator` for a series of packets
* @param out updated series of byte stream data produced through successful packet encoding;
* defaults to an empty list
* @return a series of byte stream data produced through successful packet encoding
*/
@tailrec private def recursiveEncode(
iter: Iterator[PlanetSidePacket],
out: List[ByteVector] = List()
): List[ByteVector] = {
if (!iter.hasNext) {
out
} else {
import net.psforever.packet.{PlanetSideControlPacket, PlanetSideGamePacket}
iter.next match {
case msg: PlanetSideGamePacket =>
PacketCoding.EncodePacket(msg) match {
case Successful(bytecode) =>
recursiveEncode(iter, out :+ bytecode.toByteVector)
case Failure(e) =>
log.warn(s"game packet $msg, part of a bundle, did not encode - $e")
recursiveEncode(iter, out)
}
case msg: PlanetSideControlPacket =>
PacketCoding.EncodePacket(msg) match {
case Successful(bytecode) =>
recursiveEncode(iter, out :+ bytecode.toByteVector)
case Failure(e) =>
log.warn(s"control packet $msg, part of a bundle, did not encode - $e")
recursiveEncode(iter, out)
}
case _ =>
recursiveEncode(iter, out)
}
}
}
/**
* Accept a series of byte stream data and sort into sequential size-limited buckets of the same byte streams.
* Note that elements that exceed `lim` by themselves are always sorted into their own buckets.
* @param iter an `Iterator` of a series of byte stream data
* @param lim the maximum stream length permitted
* @param curr the stream length of the current bucket
* @param out updated series of byte stream data stored in buckets
* @return a series of byte stream data stored in buckets
*/
@tailrec private def recursiveFillPacketBuckets(
iter: Iterator[ByteVector],
lim: Int,
curr: Int = 0,
out: List[mutable.ListBuffer[ByteVector]] = List(mutable.ListBuffer())
): List[mutable.ListBuffer[ByteVector]] = {
if (!iter.hasNext) {
out
} else {
val data = iter.next
var len = data.length.toInt
len = len + (if (len < 256) { 1 }
else if (len < 65536) { 2 }
else { 4 }) //space for the prefixed length byte(s)
if (curr + len > lim && out.last.nonEmpty) { //bucket must have something in it before swapping
recursiveFillPacketBuckets(iter, lim, len, out :+ mutable.ListBuffer(data))
} else {
out.last += data
recursiveFillPacketBuckets(iter, lim, curr + len, out)
}
}
}
}
object PacketCodingActor {
final val MTU_LIMIT_BYTES: Int = 467
private final case class SubslotResend()
}

View file

@ -0,0 +1,103 @@
package net.psforever.login
import java.net.InetSocketAddress
import akka.actor.MDCContextAware.Implicits._
import akka.actor.{ActorContext, ActorRef, PoisonPill, _}
import com.github.nscala_time.time.Imports._
import scodec.bits._
sealed trait SessionState
final case class New() extends SessionState
final case class Related() extends SessionState
final case class Handshaking() extends SessionState
final case class Established() extends SessionState
final case class Closing() extends SessionState
final case class Closed() extends SessionState
class Session(
val sessionId: Long,
val socketAddress: InetSocketAddress,
returnActor: ActorRef,
sessionPipeline: List[SessionPipeline]
)(
implicit val context: ActorContext,
implicit val self: ActorRef
) {
var state: SessionState = New()
val sessionCreatedTime: DateTime = DateTime.now
var sessionEndedTime: DateTime = DateTime.now
val pipeline = sessionPipeline.map { actor =>
val a = context.actorOf(actor.props, actor.nameTemplate + sessionId.toString)
context.watch(a)
a
}
val pipelineIter = pipeline.iterator
if (pipelineIter.hasNext) {
pipelineIter.next ! HelloFriend(sessionId, pipelineIter)
}
// statistics
var bytesSent: Long = 0
var bytesReceived: Long = 0
var inboundPackets: Long = 0
var outboundPackets: Long = 0
var lastInboundEvent: Long = System.nanoTime()
var lastOutboundEvent: Long = System.nanoTime()
var inboundPacketRate: Double = 0.0
var outboundPacketRate: Double = 0.0
var inboundBytesPerSecond: Double = 0.0
var outboundBytesPerSecond: Double = 0.0
def receive(packet: RawPacket): Unit = {
bytesReceived += packet.data.size
inboundPackets += 1
lastInboundEvent = System.nanoTime()
pipeline.head !> packet
}
def send(packet: ByteVector): Unit = {
bytesSent += packet.size
outboundPackets += 1
lastOutboundEvent = System.nanoTime()
returnActor ! SendPacket(packet, socketAddress)
}
def dropSession(graceful: Boolean) = {
pipeline.foreach(context.unwatch)
pipeline.foreach(_ ! PoisonPill)
sessionEndedTime = DateTime.now
setState(Closed())
}
def getState = state
def setState(newState: SessionState): Unit = {
state = newState
}
def getPipeline: List[ActorRef] = pipeline
def getTotalBytes = {
bytesSent + bytesReceived
}
def timeSinceLastInboundEvent = {
(System.nanoTime() - lastInboundEvent) / 1000000
}
def timeSinceLastOutboundEvent = {
(System.nanoTime() - lastOutboundEvent) / 1000000
}
override def toString: String = {
s"Session($sessionId, $getTotalBytes)"
}
}

View file

@ -0,0 +1,194 @@
package net.psforever.login
import java.net.InetSocketAddress
import akka.actor.SupervisorStrategy.Stop
import akka.actor._
import net.psforever.packet.PacketCoding
import net.psforever.packet.control.ConnectionClose
import net.psforever.util.Config
import org.log4s.MDC
import scodec.bits._
import services.ServiceManager
import services.ServiceManager.Lookup
import services.account.{IPAddress, StoreIPAddress}
import scala.collection.mutable
import scala.concurrent.duration._
sealed trait SessionRouterAPI
final case class RawPacket(data: ByteVector) extends SessionRouterAPI
final case class ResponsePacket(data: ByteVector) extends SessionRouterAPI
final case class DropSession(id: Long, reason: String) extends SessionRouterAPI
final case class SessionReaper() extends SessionRouterAPI
case class SessionPipeline(nameTemplate: String, props: Props)
/**
* Login sessions are divided between two actors. The crypto session actor transparently handles all of the cryptographic
* setup of the connection. Once a correct crypto session has been established, all packets, after being decrypted
* will be passed on to the login session actor. This actor has important state that is used to maintain the login
* session.
*
* > PlanetSide Session Pipeline <
*
* read() route decrypt
* UDP Socket -----> [Session Router] -----> [Crypto Actor] -----> [Session Actor]
* /|\ | /|\ | /|\ |
* | write() | | encrypt | | response |
* +--------------+ +-----------+ +-----------------+
*/
class SessionRouter(role: String, pipeline: List[SessionPipeline]) extends Actor with MDCContextAware {
private[this] val log = org.log4s.getLogger(self.path.name)
import scala.concurrent.ExecutionContext.Implicits.global
val sessionReaper = context.system.scheduler.scheduleWithFixedDelay(10 seconds, 5 seconds, self, SessionReaper())
val idBySocket = mutable.Map[InetSocketAddress, Long]()
val sessionById = mutable.Map[Long, Session]()
val sessionByActor = mutable.Map[ActorRef, Session]()
val closePacket = PacketCoding.EncodePacket(ConnectionClose()).require.bytes
var accountIntermediary: ActorRef = ActorRef.noSender
var sessionId = 0L // this is a connection session, not an actual logged in session ID
var inputRef: ActorRef = ActorRef.noSender
override def supervisorStrategy = OneForOneStrategy() { case _ => Stop }
override def preStart = {
log.info(s"SessionRouter (for ${role}s) initializing ...")
}
def receive = initializing
def initializing: Receive = {
case Hello() =>
inputRef = sender()
ServiceManager.serviceManager ! Lookup("accountIntermediary")
case ServiceManager.LookupResult("accountIntermediary", endpoint) =>
accountIntermediary = endpoint
log.info(s"SessionRouter starting; ready for $role sessions")
context.become(started)
case default =>
log.error(s"Unknown or unexpected message $default before being properly started. Stopping completely...")
context.stop(self)
}
override def postStop() = {
sessionReaper.cancel()
}
def started: Receive = {
case _ @ReceivedPacket(msg, from) =>
var session: Session = null
if (!idBySocket.contains(from)) {
session = createNewSession(from)
} else {
val id = idBySocket { from }
session = sessionById { id }
}
if (session.state != Closed()) {
MDC("sessionId") = session.sessionId.toString
log.trace(s"RECV: $msg -> ${session.getPipeline.head.path.name}")
session.receive(RawPacket(msg))
MDC.clear()
}
case ResponsePacket(msg) =>
val session = sessionByActor.get(sender())
if (session.isDefined) {
if (session.get.state != Closed()) {
MDC("sessionId") = session.get.sessionId.toString
log.trace(s"SEND: $msg -> ${inputRef.path.name}")
session.get.send(msg)
MDC.clear()
}
} else {
log.error("Dropped old response packet from actor " + sender().path.name)
}
case DropSession(id, reason) =>
val session = sessionById.get(id)
if (session.isDefined) {
removeSessionById(id, reason, graceful = true)
} else {
log.error(s"Requested to drop non-existent session ID=$id from ${sender()}")
}
case SessionReaper() =>
val inboundGrace = Config.app.network.session.inboundGraceTime.toMillis
val outboundGrace = Config.app.network.session.outboundGraceTime.toMillis
sessionById.foreach {
case (id, session) =>
log.trace(session.toString)
if (session.getState == Closed()) {
// clear mappings
session.getPipeline.foreach(sessionByActor remove)
sessionById.remove(id)
idBySocket.remove(session.socketAddress)
log.debug(s"Reaped session ID=$id")
} else if (session.timeSinceLastInboundEvent > inboundGrace) {
removeSessionById(id, "session timed out (inbound)", graceful = false)
} else if (session.timeSinceLastOutboundEvent > outboundGrace) {
removeSessionById(id, "session timed out (outbound)", graceful = true) // tell client to STFU
}
}
case Terminated(actor) =>
val terminatedSession = sessionByActor.get(actor)
if (terminatedSession.isDefined) {
removeSessionById(terminatedSession.get.sessionId, s"${actor.path.name} died", graceful = true)
} else {
log.error("Received an invalid actor Termination from " + actor.path.name)
}
case default =>
log.error(s"Unknown message $default from " + sender().path)
}
def createNewSession(address: InetSocketAddress) = {
val id = newSessionId
val session = new Session(id, address, inputRef, pipeline)
// establish mappings for easy lookup
idBySocket { address } = id
sessionById { id } = session
session.getPipeline.foreach { actor =>
sessionByActor { actor } = session
}
log.info(s"New session ID=$id from " + address.toString)
if (role == "Login") {
accountIntermediary ! StoreIPAddress(id, new IPAddress(address))
}
session
}
def removeSessionById(id: Long, reason: String, graceful: Boolean): Unit = {
val sessionOption = sessionById.get(id)
if (sessionOption.isEmpty)
return
val session: Session = sessionOption.get
if (graceful) {
for (_ <- 0 to 5) {
session.send(closePacket)
}
}
// kill all session specific actors
session.dropSession(graceful)
log.info(s"Dropping session ID=$id (reason: $reason)")
}
def newSessionId = {
val oldId = sessionId
sessionId += 1
oldId
}
}

View file

@ -0,0 +1,50 @@
package net.psforever.login
import java.net.{InetAddress, InetSocketAddress}
import akka.actor.SupervisorStrategy.Stop
import akka.actor.{Actor, ActorRef, OneForOneStrategy, Props}
import akka.io._
class TcpListener[T <: Actor](actorClass: Class[T], nextActorName: String, listenAddress: InetAddress, port: Int)
extends Actor {
private val log = org.log4s.getLogger(self.path.name)
override def supervisorStrategy =
OneForOneStrategy() {
case _ => Stop
}
import context.system
IO(Tcp) ! Tcp.Bind(self, new InetSocketAddress(listenAddress, port))
var sessionId = 0L
var bytesRecevied = 0L
var bytesSent = 0L
var nextActor: ActorRef = ActorRef.noSender
def receive = {
case Tcp.Bound(local) =>
log.info(s"Now listening on TCP:$local")
context.become(ready(sender()))
case Tcp.CommandFailed(Tcp.Bind(_, address, _, _, _)) =>
log.error("Failed to bind to the network interface: " + address)
context.system.terminate()
case default =>
log.error(s"Unexpected message $default")
}
def ready(socket: ActorRef): Receive = {
case Tcp.Connected(remote, local) =>
val connection = sender()
val session = sessionId
val handler = context.actorOf(Props(actorClass, remote, connection), nextActorName + session)
connection ! Tcp.Register(handler)
sessionId += 1
case Tcp.Unbind => socket ! Tcp.Unbind
case Tcp.Unbound => context.stop(self)
case default => log.error(s"Unhandled message: $default")
}
}

View file

@ -0,0 +1,79 @@
package net.psforever.login
import java.net.{InetAddress, InetSocketAddress}
import akka.actor.SupervisorStrategy.Stop
import akka.actor.{Actor, ActorRef, OneForOneStrategy, Props, Terminated}
import akka.io._
import scodec.bits._
import scodec.interop.akka._
final case class ReceivedPacket(msg: ByteVector, from: InetSocketAddress)
final case class SendPacket(msg: ByteVector, to: InetSocketAddress)
final case class Hello()
final case class HelloFriend(sessionId: Long, next: Iterator[ActorRef])
class UdpListener(
nextActorProps: Props,
nextActorName: String,
listenAddress: InetAddress,
port: Int,
netParams: Option[NetworkSimulatorParameters]
) extends Actor {
private val log = org.log4s.getLogger(self.path.name)
override def supervisorStrategy =
OneForOneStrategy() {
case _ => Stop
}
import context.system
// If we have network parameters, start the network simulator
if (netParams.isDefined) {
// See http://www.cakesolutions.net/teamblogs/understanding-akkas-recommended-practice-for-actor-creation-in-scala
// For why we cant do Props(new Actor) here
val sim = context.actorOf(Props(classOf[UdpNetworkSimulator], self, netParams.get))
IO(Udp).tell(Udp.Bind(sim, new InetSocketAddress(listenAddress, port)), sim)
} else {
IO(Udp) ! Udp.Bind(self, new InetSocketAddress(listenAddress, port))
}
var bytesRecevied = 0L
var bytesSent = 0L
var nextActor: ActorRef = ActorRef.noSender
def receive = {
case Udp.Bound(local) =>
log.info(s"Now listening on UDP:$local")
createNextActor()
context.become(ready(sender()))
case Udp.CommandFailed(Udp.Bind(_, address, _)) =>
log.error("Failed to bind to the network interface: " + address)
context.system.terminate()
case default =>
log.error(s"Unexpected message $default")
}
def ready(socket: ActorRef): Receive = {
case SendPacket(msg, to) =>
bytesSent += msg.size
socket ! Udp.Send(msg.toByteString, to)
case Udp.Received(data, remote) =>
bytesRecevied += data.size
nextActor ! ReceivedPacket(data.toByteVector, remote)
case Udp.Unbind => socket ! Udp.Unbind
case Udp.Unbound => context.stop(self)
case Terminated(actor) =>
log.error(s"Next actor ${actor.path.name} has died...restarting")
createNextActor()
case default => log.error(s"Unhandled message: $default")
}
def createNextActor() = {
nextActor = context.actorOf(nextActorProps, nextActorName)
context.watch(nextActor)
nextActor ! Hello()
}
}

View file

@ -0,0 +1,146 @@
package net.psforever.login
import akka.actor.{Actor, ActorRef}
import akka.io._
import scala.collection.mutable
import scala.concurrent.duration._
import scala.util.Random
/** Parameters for the Network simulator
*
* @param packetLoss The percentage from [0.0, 1.0] that a packet will be lost
* @param packetDelay The end-to-end delay (ping) of all packets
* @param packetReorderingChance The percentage from [0.0, 1.0] that a packet will be reordered
* @param packetReorderingTime The absolute adjustment in milliseconds that a packet can have (either
* forward or backwards in time)
*/
case class NetworkSimulatorParameters(
packetLoss: Double,
packetDelay: Long,
packetReorderingChance: Double,
packetReorderingTime: Long
) {
assert(packetLoss >= 0.0 && packetLoss <= 1.0)
assert(packetDelay >= 0)
assert(packetReorderingChance >= 0.0 && packetReorderingChance <= 1.0)
assert(packetReorderingTime >= 0)
override def toString =
"NetSimParams: loss %.2f%% / delay %dms / reorder %.2f%% / reorder +/- %dms".format(
packetLoss * 100,
packetDelay,
packetReorderingChance * 100,
packetReorderingTime
)
}
class UdpNetworkSimulator(server: ActorRef, params: NetworkSimulatorParameters) extends Actor {
private val log = org.log4s.getLogger
import scala.concurrent.ExecutionContext.Implicits.global
//******* Variables
val packetDelayDuration = (params.packetDelay / 2).milliseconds
type QueueItem = (Udp.Message, Long)
// sort in ascending order (older things get dequeued first)
implicit val QueueItem = Ordering.by[QueueItem, Long](_._2).reverse
val inPacketQueue = mutable.PriorityQueue[QueueItem]()
val outPacketQueue = mutable.PriorityQueue[QueueItem]()
val chaos = new Random()
var interface = ActorRef.noSender
def receive = {
case UdpNetworkSimulator.ProcessInputQueue() =>
val time = System.nanoTime()
var exit = false
while (inPacketQueue.nonEmpty && !exit) {
val lastTime = time - inPacketQueue.head._2
// this packet needs to be sent within 20 milliseconds or more
if (lastTime >= 20000000) {
server.tell(inPacketQueue.dequeue._1, interface)
} else {
schedule(lastTime.nanoseconds, outbound = false)
exit = true
}
}
case UdpNetworkSimulator.ProcessOutputQueue() =>
val time = System.nanoTime()
var exit = false
while (outPacketQueue.nonEmpty && !exit) {
val lastTime = time - outPacketQueue.head._2
// this packet needs to be sent within 20 milliseconds or more
if (lastTime >= 20000000) {
interface.tell(outPacketQueue.dequeue._1, server)
} else {
schedule(lastTime.nanoseconds, outbound = true)
exit = true
}
}
// outbound messages
case msg @ Udp.Send(payload, target, _) =>
handlePacket(msg, outPacketQueue, outbound = true)
// inbound messages
case msg @ Udp.Received(payload, sender) =>
handlePacket(msg, inPacketQueue, outbound = false)
case msg @ Udp.Bound(address) =>
interface = sender()
log.info(s"Hooked ${server.path} for network simulation")
server.tell(msg, self) // make sure the server sends *us* the packets
case default =>
val from = sender()
if (from == server)
interface.tell(default, server)
else if (from == interface)
server.tell(default, interface)
else
log.error("Unexpected sending Actor " + from.path)
}
def handlePacket(message: Udp.Message, queue: mutable.PriorityQueue[QueueItem], outbound: Boolean) = {
val name: String = if (outbound) "OUT" else "IN"
val queue: mutable.PriorityQueue[QueueItem] = if (outbound) outPacketQueue else inPacketQueue
if (chaos.nextDouble() > params.packetLoss) {
// if the message queue is empty, then we need to reschedule our task
if (queue.isEmpty)
schedule(packetDelayDuration, outbound)
// perform a reordering
if (chaos.nextDouble() <= params.packetReorderingChance) {
// creates the range (-1.0, 1.0)
// time adjustment to move the packet (forward or backwards in time)
val adj = (2 * (chaos.nextDouble() - 0.5) * params.packetReorderingTime).toLong
queue += ((message, System.nanoTime() + adj * 1000000))
log.debug(s"Reordered $name by ${adj}ms - $message")
} else { // normal message
queue += ((message, System.nanoTime()))
}
} else {
log.debug(s"Dropped $name - $message")
}
}
def schedule(duration: FiniteDuration, outbound: Boolean) =
context.system.scheduler.scheduleOnce(
packetDelayDuration,
self,
if (outbound) UdpNetworkSimulator.ProcessOutputQueue() else UdpNetworkSimulator.ProcessInputQueue()
)
}
object UdpNetworkSimulator {
//******* Internal messages
private final case class ProcessInputQueue()
private final case class ProcessOutputQueue()
}

View file

@ -0,0 +1,650 @@
package net.psforever.login
import akka.actor.ActorRef
import akka.pattern.{AskTimeoutException, ask}
import akka.util.Timeout
import net.psforever.objects.equipment.{Ammo, Equipment}
import net.psforever.objects.guid.{GUIDTask, Task, TaskResolver}
import net.psforever.objects.inventory.{Container, InventoryItem}
import net.psforever.objects.serverobject.PlanetSideServerObject
import net.psforever.objects.serverobject.containable.Containable
import net.psforever.objects.zones.Zone
import net.psforever.objects.{AmmoBox, GlobalDefinitions, Player, Tool}
import net.psforever.packet.game.ObjectHeldMessage
import net.psforever.types.{PlanetSideGUID, TransactionType, Vector3}
import services.Service
import services.avatar.{AvatarAction, AvatarServiceMessage}
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.Future
import scala.concurrent.duration._
import scala.language.implicitConversions
import scala.util.{Failure, Success}
object WorldSession {
/**
* Convert a boolean value into an integer value.
* Use: `true:Int` or `false:Int`
* @param b `true` or `false` (or `null`)
* @return 1 for `true`; 0 for `false`
*/
implicit def boolToInt(b: Boolean): Int = if (b) 1 else 0
private implicit val timeout = new Timeout(5000 milliseconds)
/**
* Use this for placing equipment that has yet to be registered into a container,
* such as in support of changing ammunition types in `Tool` objects (weapons).
* If the object can not be placed into the container, it will be dropped onto the ground.
* It will also be dropped if it takes too long to be placed.
* Item swapping during the placement is not allowed.
* @see `ask`
* @see `ChangeAmmoMessage`
* @see `Containable.CanNotPutItemInSlot`
* @see `Containable.PutItemAway`
* @see `Future.onComplete`
* @see `Future.recover`
* @see `tell`
* @see `Zone.Ground.DropItem`
* @param obj the container
* @param item the item being manipulated
* @return a `Future` that anticipates the resolution to this manipulation
*/
def PutEquipmentInInventoryOrDrop(obj: PlanetSideServerObject with Container)(item: Equipment): Future[Any] = {
val localContainer = obj
val localItem = item
val result = ask(localContainer.Actor, Containable.PutItemAway(localItem))
result.onComplete {
case Failure(_) | Success(_: Containable.CanNotPutItemInSlot) =>
localContainer.Zone.Ground.tell(
Zone.Ground.DropItem(localItem, localContainer.Position, Vector3.z(localContainer.Orientation.z)),
localContainer.Actor
)
case _ => ;
}
result
}
/**
* Use this for placing equipment that has yet to be registered into a container,
* such as in support of changing ammunition types in `Tool` objects (weapons).
* Equipment will go wherever it fits in containing object, or be dropped if it fits nowhere.
* Item swapping during the placement is not allowed.
* @see `ChangeAmmoMessage`
* @see `GUIDTask.RegisterEquipment`
* @see `PutEquipmentInInventoryOrDrop`
* @see `Task`
* @see `TaskResolver.GiveTask`
* @param obj the container
* @param item the item being manipulated
* @return a `TaskResolver` object
*/
def PutNewEquipmentInInventoryOrDrop(
obj: PlanetSideServerObject with Container
)(item: Equipment): TaskResolver.GiveTask = {
val localZone = obj.Zone
TaskResolver.GiveTask(
new Task() {
private val localContainer = obj
private val localItem = item
override def isComplete: Task.Resolution.Value = Task.Resolution.Success
def Execute(resolver: ActorRef): Unit = {
PutEquipmentInInventoryOrDrop(localContainer)(localItem)
resolver ! Success(this)
}
},
List(GUIDTask.RegisterEquipment(item)(localZone.GUID))
)
}
/**
* Use this for obtaining new equipment from a loadout specification.
* The loadout specification contains a specific slot position for placing the item.
* This request will (probably) be coincidental with a number of other such requests based on that loadout
* so items must be rigidly placed else cascade into a chaostic order.
* Item swapping during the placement is not allowed.
* @see `ask`
* @see `AvatarAction.ObjectDelete`
* @see `ChangeAmmoMessage`
* @see `Containable.CanNotPutItemInSlot`
* @see `Containable.PutItemAway`
* @see `Future.onComplete`
* @see `Future.recover`
* @see `GUIDTask.UnregisterEquipment`
* @see `tell`
* @see `Zone.AvatarEvents`
* @param obj the container
* @param taskResolver na
* @param item the item being manipulated
* @param slot na
* @return a `Future` that anticipates the resolution to this manipulation
*/
def PutEquipmentInInventorySlot(
obj: PlanetSideServerObject with Container,
taskResolver: ActorRef
)(item: Equipment, slot: Int): Future[Any] = {
val localContainer = obj
val localItem = item
val localResolver = taskResolver
val result = ask(localContainer.Actor, Containable.PutItemInSlotOnly(localItem, slot))
result.onComplete {
case Failure(_) | Success(_: Containable.CanNotPutItemInSlot) =>
localResolver ! GUIDTask.UnregisterEquipment(localItem)(localContainer.Zone.GUID)
case _ => ;
}
result
}
/**
* Use this for obtaining new equipment from a loadout specification.
* The loadout specification contains a specific slot position for placing the item.
* This request will (probably) be coincidental with a number of other such requests based on that loadout
* so items must be rigidly placed else cascade into a chaostic order.
* Item swapping during the placement is not allowed.
* @see `GUIDTask.RegisterEquipment`
* @see `PutEquipmentInInventorySlot`
* @see `Task`
* @see `TaskResolver.GiveTask`
* @param obj the container
* @param taskResolver na
* @param item the item being manipulated
* @param slot where the item will be placed in the container
* @return a `TaskResolver` object
*/
def PutLoadoutEquipmentInInventory(
obj: PlanetSideServerObject with Container,
taskResolver: ActorRef
)(item: Equipment, slot: Int): TaskResolver.GiveTask = {
val localZone = obj.Zone
TaskResolver.GiveTask(
new Task() {
private val localContainer = obj
private val localItem = item
private val localSlot = slot
private val localFunc: (Equipment, Int) => Future[Any] = PutEquipmentInInventorySlot(obj, taskResolver)
override def Timeout: Long = 1000
override def isComplete: Task.Resolution.Value = {
if (localItem.HasGUID && localContainer.Find(localItem).nonEmpty)
Task.Resolution.Success
else
Task.Resolution.Incomplete
}
override def Description: String = s"PutEquipmentInInventorySlot - ${localItem.Definition.Name}"
def Execute(resolver: ActorRef): Unit = {
localFunc(localItem, localSlot)
resolver ! Success(this)
}
},
List(GUIDTask.RegisterEquipment(item)(localZone.GUID))
)
}
/**
* Used for purchasing new equipment from a terminal and placing it somewhere in a player's loadout.
* Two levels of query are performed here based on the behavior expected of the item.
* First, an attempt is made to place the item anywhere in the target container as long as it does not cause swap items to be generated.
* Second, if it fails admission to the target container, an attempt is made to place it into the target player's free hand.
* If the container and the suggested player are the same, it will skip the second attempt.
* As a terminal operation, the player must receive a report regarding whether the transaction was successful.
* @see `ask`
* @see `Containable.CanNotPutItemInSlot`
* @see `Containable.PutItemInSlotOnly`
* @see `GUIDTask.RegisterEquipment`
* @see `GUIDTask.UnregisterEquipment`
* @see `Future.onComplete`
* @see `PutEquipmentInInventorySlot`
* @see `TerminalMessageOnTimeout`
* @param obj the container
* @param taskResolver na
* @param player na
* @param term na
* @param item the item being manipulated
* @return a `TaskResolver` object
*/
def BuyNewEquipmentPutInInventory(
obj: PlanetSideServerObject with Container,
taskResolver: ActorRef,
player: Player,
term: PlanetSideGUID
)(item: Equipment): TaskResolver.GiveTask = {
val localZone = obj.Zone
TaskResolver.GiveTask(
new Task() {
private val localContainer = obj
private val localItem = item
private val localPlayer = player
private val localResolver = taskResolver
private val localTermMsg: Boolean => Unit = TerminalResult(term, localPlayer, TransactionType.Buy)
override def Timeout: Long = 1000
override def isComplete: Task.Resolution.Value = {
if (localItem.HasGUID && localContainer.Find(localItem).nonEmpty)
Task.Resolution.Success
else
Task.Resolution.Incomplete
}
def Execute(resolver: ActorRef): Unit = {
TerminalMessageOnTimeout(
ask(localContainer.Actor, Containable.PutItemAway(localItem)),
localTermMsg
)
.onComplete {
case Failure(_) | Success(_: Containable.CanNotPutItemInSlot) =>
if (localContainer != localPlayer) {
TerminalMessageOnTimeout(
PutEquipmentInInventorySlot(localPlayer, localResolver)(localItem, Player.FreeHandSlot),
localTermMsg
)
.onComplete {
case Failure(_) | Success(_: Containable.CanNotPutItemInSlot) =>
localTermMsg(false)
case _ =>
localTermMsg(true)
}
} else {
localResolver ! GUIDTask.UnregisterEquipment(localItem)(localContainer.Zone.GUID)
localTermMsg(false)
}
case _ =>
localTermMsg(true)
}
resolver ! Success(this)
}
},
List(GUIDTask.RegisterEquipment(item)(localZone.GUID))
)
}
/**
* The primary use is to register new mechanized assault exo-suit armaments,
* place the newly registered weapon in hand,
* and then raise that hand (draw that slot) so that the weapon is active.
* (Players in MAX suits can not manipulate their drawn slot manually.)
* In general, this can be used for any equipment that is to be equipped to a player's hand then immediately drawn.
* Do not allow the item to be (mis)placed in any available slot.
* Item swapping during the placement is not allowed and the possibility should be proactively avoided.
* @throws `RuntimeException` if slot is not a player visible slot (holsters)
* @see `ask`
* @see `AvatarAction.ObjectDelete`
* @see `AvatarAction.SendResponse`
* @see `Containable.CanNotPutItemInSlot`
* @see `Containable.PutItemInSlotOnly`
* @see `GUIDTask.RegisterEquipment`
* @see `GUIDTask.UnregisterEquipment`
* @see `Future.onComplete`
* @see `ObjectHeldMessage`
* @see `Player.DrawnSlot`
* @see `Player.LastDrawnSlot`
* @see `Service.defaultPlayerGUID`
* @see `TaskResolver.GiveTask`
* @see `Zone.AvatarEvents`
* @param player the player whose visible slot will be equipped and drawn
* @param taskResolver na
* @param item the item to equip
* @param slot the slot in which the item will be equipped
* @return a `TaskResolver` object
*/
def HoldNewEquipmentUp(player: Player, taskResolver: ActorRef)(item: Equipment, slot: Int): TaskResolver.GiveTask = {
if (player.VisibleSlots.contains(slot)) {
val localZone = player.Zone
TaskResolver.GiveTask(
new Task() {
private val localPlayer = player
private val localGUID = player.GUID
private val localItem = item
private val localSlot = slot
private val localResolver = taskResolver
override def Timeout: Long = 1000
override def isComplete: Task.Resolution.Value = {
if (localPlayer.DrawnSlot == localSlot)
Task.Resolution.Success
else
Task.Resolution.Incomplete
}
def Execute(resolver: ActorRef): Unit = {
ask(localPlayer.Actor, Containable.PutItemInSlotOnly(localItem, localSlot))
.onComplete {
case Failure(_) | Success(_: Containable.CanNotPutItemInSlot) =>
localResolver ! GUIDTask.UnregisterEquipment(localItem)(localZone.GUID)
case _ =>
if (localPlayer.DrawnSlot != Player.HandsDownSlot) {
localPlayer.DrawnSlot = Player.HandsDownSlot
localZone.AvatarEvents ! AvatarServiceMessage(
localPlayer.Name,
AvatarAction.SendResponse(
Service.defaultPlayerGUID,
ObjectHeldMessage(localGUID, Player.HandsDownSlot, false)
)
)
localZone.AvatarEvents ! AvatarServiceMessage(
localZone.Id,
AvatarAction.ObjectHeld(localGUID, localPlayer.LastDrawnSlot)
)
}
localPlayer.DrawnSlot = localSlot
localZone.AvatarEvents ! AvatarServiceMessage(
localZone.Id,
AvatarAction.SendResponse(Service.defaultPlayerGUID, ObjectHeldMessage(localGUID, localSlot, false))
)
}
resolver ! Success(this)
}
},
List(GUIDTask.RegisterEquipment(item)(localZone.GUID))
)
} else {
//TODO log.error
throw new RuntimeException(s"provided slot $slot is not a player visible slot (holsters)")
}
}
/**
* Get an item from the ground and put it into the given container.
* The zone in which the item is found is expected to be the same in which the container object is located.
* If the object can not be placed into the container, it is put back on the ground.
* The item that was collected off the ground, if it is placed back on the ground,
* will be positioned with respect to the container object rather than its original location.
* @see `ask`
* @see `AvatarAction.ObjectDelete`
* @see `Future.onComplete`
* @see `Zone.AvatarEvents`
* @see `Zone.Ground.CanNotPickUpItem`
* @see `Zone.Ground.ItemInHand`
* @see `Zone.Ground.PickUpItem`
* @see `PutEquipmentInInventoryOrDrop`
* @param obj the container into which the item will be placed
* @param item the item being collected from off the ground of the container's zone
* @return a `Future` that anticipates the resolution to this manipulation
*/
def PickUpEquipmentFromGround(obj: PlanetSideServerObject with Container)(item: Equipment): Future[Any] = {
val localZone = obj.Zone
val localContainer = obj
val localItem = item
val future = ask(localZone.Ground, Zone.Ground.PickupItem(item.GUID))
future.onComplete {
case Success(Zone.Ground.ItemInHand(_)) =>
PutEquipmentInInventoryOrDrop(localContainer)(localItem)
case Success(Zone.Ground.CanNotPickupItem(_, item_guid, _)) =>
localZone.GUID(item_guid) match {
case Some(_) => ;
case None => //acting on old data?
localZone.AvatarEvents ! AvatarServiceMessage(
localZone.Id,
AvatarAction.ObjectDelete(Service.defaultPlayerGUID, item_guid)
)
}
case _ => ;
}
future
}
/**
* Remove an item from a container and drop it on the ground.
* @see `ask`
* @see `AvatarAction.ObjectDelete`
* @see `Containable.ItemFromSlot`
* @see `Containable.RemoveItemFromSlot`
* @see `Future.onComplete`
* @see `Future.recover`
* @see `tell`
* @see `Zone.AvatarEvents`
* @see `Zone.Ground.DropItem`
* @param obj the container to search
* @param item the item to find and remove from the container
* @param pos an optional position where to drop the item on the ground;
* expected override from original container's position
* @return a `Future` that anticipates the resolution to this manipulation
*/
def DropEquipmentFromInventory(
obj: PlanetSideServerObject with Container
)(item: Equipment, pos: Option[Vector3] = None): Future[Any] = {
val localContainer = obj
val localItem = item
val localPos = pos
val result = ask(localContainer.Actor, Containable.RemoveItemFromSlot(localItem))
result.onComplete {
case Success(Containable.ItemFromSlot(_, Some(_), Some(_))) =>
localContainer.Zone.Ground.tell(
Zone.Ground
.DropItem(localItem, localPos.getOrElse(localContainer.Position), Vector3.z(localContainer.Orientation.z)),
localContainer.Actor
)
case _ => ;
}
result
}
/**
* Remove an item from a container and delete it.
* @see `ask`
* @see `AvatarAction.ObjectDelete`
* @see `Containable.ItemFromSlot`
* @see `Containable.RemoveItemFromSlot`
* @see `Future.onComplete`
* @see `Future.recover`
* @see `GUIDTask.UnregisterEquipment`
* @see `Zone.AvatarEvents`
* @param obj the container to search
* @param taskResolver na
* @param item the item to find and remove from the container
* @return a `Future` that anticipates the resolution to this manipulation
*/
def RemoveOldEquipmentFromInventory(obj: PlanetSideServerObject with Container, taskResolver: ActorRef)(
item: Equipment
): Future[Any] = {
val localContainer = obj
val localItem = item
val localResolver = taskResolver
val result = ask(localContainer.Actor, Containable.RemoveItemFromSlot(localItem))
result.onComplete {
case Success(Containable.ItemFromSlot(_, Some(_), Some(_))) =>
localResolver ! GUIDTask.UnregisterEquipment(localItem)(localContainer.Zone.GUID)
case _ =>
}
result
}
/**
* Primarily, remove an item from a container and delete it.
* As a terminal operation, the player must receive a report regarding whether the transaction was successful.
* At the end of a successful transaction, and only a successful transaction,
* the item that was removed is no longer considered a valid game object.
* Contrasting `RemoveOldEquipmentFromInventory` which identifies the actual item to be eliminated,
* this function uses the slot where the item is (should be) located.
* @see `ask`
* @see `Containable.ItemFromSlot`
* @see `Containable.RemoveItemFromSlot`
* @see `Future.onComplete`
* @see `Future.recover`
* @see `GUIDTask.UnregisterEquipment`
* @see `RemoveOldEquipmentFromInventory`
* @see `TerminalMessageOnTimeout`
* @see `TerminalResult`
* @param obj the container to search
* @param taskResolver na
* @param player the player who used the terminal
* @param term the unique identifier number of the terminal
* @param slot from which slot the equipment is to be removed
* @return a `Future` that anticipates the resolution to this manipulation
*/
def SellEquipmentFromInventory(
obj: PlanetSideServerObject with Container,
taskResolver: ActorRef,
player: Player,
term: PlanetSideGUID
)(slot: Int): Future[Any] = {
val localContainer = obj
val localPlayer = player
val localSlot = slot
val localResolver = taskResolver
val localTermMsg: Boolean => Unit = TerminalResult(term, localPlayer, TransactionType.Sell)
val result = TerminalMessageOnTimeout(
ask(localContainer.Actor, Containable.RemoveItemFromSlot(localSlot)),
localTermMsg
)
result.onComplete {
case Success(Containable.ItemFromSlot(_, Some(item), Some(_))) =>
localResolver ! GUIDTask.UnregisterEquipment(item)(localContainer.Zone.GUID)
localTermMsg(true)
case _ =>
localTermMsg(false)
}
result
}
/**
* If a timeout occurs on the manipulation, declare a terminal transaction failure.
* @see `AskTimeoutException`
* @see `recover`
* @param future the item manipulation's `Future` object
* @param terminalMessage how to call the terminal message
* @return a `Future` that anticipates the resolution to this manipulation
*/
def TerminalMessageOnTimeout(future: Future[Any], terminalMessage: Boolean => Unit): Future[Any] = {
future.recover {
case _: AskTimeoutException =>
terminalMessage(false)
}
}
/**
* Announced the result of this player's terminal use, to the player that used the terminal.
* This is a necessary step for regaining terminal use which is naturally blocked by the client after a transaction request.
* @see `AvatarAction.TerminalOrderResult`
* @see `ItemTransactionResultMessage`
* @see `TransactionType`
* @param guid the terminal's unique identifier
* @param player the player who used the terminal
* @param transaction what kind of transaction was involved in terminal use
* @param result the result of that transaction
*/
def TerminalResult(guid: PlanetSideGUID, player: Player, transaction: TransactionType.Value)(
result: Boolean
): Unit = {
player.Zone.AvatarEvents ! AvatarServiceMessage(
player.Name,
AvatarAction.TerminalOrderResult(guid, transaction, result)
)
}
/**
* Drop some items on the ground is a given location.
* The location corresponds to the previous container for those items.
* @see `Zone.Ground.DropItem`
* @param container the original object that contained the items
* @param drops the items to be dropped on the ground
*/
def DropLeftovers(container: PlanetSideServerObject with Container)(drops: List[InventoryItem]): Unit = {
//drop or retire
val zone = container.Zone
val pos = container.Position
val orient = Vector3.z(container.Orientation.z)
//TODO make a sound when dropping stuff?
drops.foreach { entry => zone.Ground.tell(Zone.Ground.DropItem(entry.obj, pos, orient), container.Actor) }
}
/**
* Within a specified `Container`, find the smallest number of `Equipment` objects of a certain qualifying type
* whose sum count is greater than, or equal to, a `desiredAmount` based on an accumulator method.<br>
* <br>
* In an occupied `List` of returned `Inventory` entries, all but the last entry is typically considered "emptied."
* For objects with contained quantities, the last entry may require having that quantity be set to a non-zero number.
* @param obj the `Container` to search
* @param filterTest test used to determine inclusivity of `Equipment` collection
* @param desiredAmount how much is requested
* @param counting test used to determine value of found `Equipment`;
* defaults to one per entry
* @return a `List` of all discovered entries totaling approximately the amount requested
*/
def FindEquipmentStock(
obj: Container,
filterTest: Equipment => Boolean,
desiredAmount: Int,
counting: Equipment => Int = DefaultCount
): List[InventoryItem] = {
var currentAmount: Int = 0
obj.Inventory.Items
.filter(item => filterTest(item.obj))
.sortBy(_.start)
.takeWhile(entry => {
val previousAmount = currentAmount
currentAmount += counting(entry.obj)
previousAmount < desiredAmount
})
}
/**
* The default counting function for an item.
* Counts the number of item(s).
* @param e the `Equipment` object
* @return the quantity;
* always one
*/
def DefaultCount(e: Equipment): Int = 1
/**
* The counting function for an item of `AmmoBox`.
* Counts the `Capacity` of the ammunition.
* @param e the `Equipment` object
* @return the quantity
*/
def CountAmmunition(e: Equipment): Int = {
e match {
case a: AmmoBox => a.Capacity
case _ => 0
}
}
/**
* The counting function for an item of `Tool` where the item is also a grenade.
* Counts the number of grenades.
* @see `GlobalDefinitions.isGrenade`
* @param e the `Equipment` object
* @return the quantity
*/
def CountGrenades(e: Equipment): Int = {
e match {
case t: Tool => (GlobalDefinitions.isGrenade(t.Definition): Int) * t.Magazine
case _ => 0
}
}
/**
* Flag an `AmmoBox` object that matches for the given ammunition type.
* @param ammo the type of `Ammo` to check
* @param e the `Equipment` object
* @return `true`, if the object is an `AmmoBox` of the correct ammunition type; `false`, otherwise
*/
def FindAmmoBoxThatUses(ammo: Ammo.Value)(e: Equipment): Boolean = {
e match {
case t: AmmoBox => t.AmmoType == ammo
case _ => false
}
}
/**
* Flag a `Tool` object that matches for loading the given ammunition type.
* @param ammo the type of `Ammo` to check
* @param e the `Equipment` object
* @return `true`, if the object is a `Tool` that loads the correct ammunition type; `false`, otherwise
*/
def FindToolThatUses(ammo: Ammo.Value)(e: Equipment): Boolean = {
e match {
case t: Tool =>
t.Definition.AmmoTypes.map { _.AmmoType }.contains(ammo)
case _ =>
false
}
}
}

View file

@ -0,0 +1,30 @@
package net.psforever.login.psadmin
import net.psforever.util.Config
import scala.collection.mutable
import scala.jdk.CollectionConverters._
object CmdInternal {
def cmdDumpConfig(args: Array[String]) = {
val config =
Config.config.root.keySet.asScala.map(key => key -> Config.config.getAnyRef(key).asInstanceOf[Any]).toMap
CommandGoodResponse(s"Dump of WorldConfig", mutable.Map(config.toSeq: _*))
}
def cmdThreadDump(args: Array[String]) = {
var data = mutable.Map[String, Any]()
val traces = Thread.getAllStackTraces().asScala
var traces_fmt = List[String]()
for ((thread, trace) <- traces) {
val info = s"Thread ${thread.getId} - ${thread.getName}\n"
traces_fmt = traces_fmt ++ List(info + trace.mkString("\n"))
}
data { "trace" } = traces_fmt
CommandGoodResponse(s"Dump of ${traces.size} threads", data)
}
}

View file

@ -0,0 +1,29 @@
package net.psforever.login.psadmin
import akka.actor.{Actor, ActorRef}
import net.psforever.objects.zones.InterstellarCluster
import scala.collection.mutable.Map
class CmdListPlayers(args: Array[String], services: Map[String, ActorRef]) extends Actor {
private[this] val log = org.log4s.getLogger(self.path.name)
override def preStart = {
services { "cluster" } ! InterstellarCluster.ListPlayers()
}
override def receive = {
case InterstellarCluster.PlayerList(players) =>
val data = Map[String, Any]()
data { "player_count" } = players.size
data { "player_list" } = Array[String]()
if (players.isEmpty) {
context.parent ! CommandGoodResponse("No players currently online!", data)
} else {
data { "player_list" } = players
context.parent ! CommandGoodResponse(s"${players.length} players online\n", data)
}
case default => log.error(s"Unexpected message $default")
}
}

View file

@ -0,0 +1,17 @@
package net.psforever.login.psadmin
import akka.actor.{Actor, ActorRef}
import scala.collection.mutable.Map
class CmdShutdown(args: Array[String], services: Map[String, ActorRef]) extends Actor {
override def preStart = {
var data = Map[String, Any]()
context.parent ! CommandGoodResponse("Shutting down", data)
context.system.terminate()
}
override def receive = {
case default =>
}
}

View file

@ -0,0 +1,187 @@
package net.psforever.login.psadmin
import java.net.InetSocketAddress
import akka.actor.{Actor, ActorRef, Props, Stash}
import akka.io.Tcp
import akka.util.ByteString
import org.json4s._
import org.json4s.native.Serialization.write
import scodec.bits._
import scodec.interop.akka._
import services.ServiceManager.Lookup
import services._
import scala.collection.mutable.Map
object PsAdminActor {
val whiteSpaceRegex = """\s+""".r
}
class PsAdminActor(peerAddress: InetSocketAddress, connection: ActorRef) extends Actor with Stash {
private[this] val log = org.log4s.getLogger(self.path.name)
val services = Map[String, ActorRef]()
val servicesToResolve = Array("cluster")
var buffer = ByteString()
implicit val formats = DefaultFormats // for JSON serialization
case class CommandCall(operation: String, args: Array[String])
override def preStart() = {
log.trace(s"PsAdmin connection started $peerAddress")
for (service <- servicesToResolve) {
ServiceManager.serviceManager ! Lookup(service)
}
}
override def receive = ServiceLookup
def ServiceLookup: Receive = {
case ServiceManager.LookupResult(service, endpoint) =>
services { service } = endpoint
if (services.size == servicesToResolve.size) {
unstashAll()
context.become(ReceiveCommand)
}
case default => stash()
}
def ReceiveCommand: Receive = {
case Tcp.Received(data) =>
buffer ++= data
var pos = -1;
var amount = 0
do {
pos = buffer.indexOf('\n')
if (pos != -1) {
val (cmd, rest) = buffer.splitAt(pos)
buffer = rest.drop(1); // drop the newline
// make sure the CN cant crash us
val line = cmd.decodeString("utf-8").trim
if (line != "") {
val tokens = PsAdminActor.whiteSpaceRegex.split(line)
val cmd = tokens.head
val args = tokens.tail
amount += 1
self ! CommandCall(cmd, args)
}
}
} while (pos != -1)
if (amount > 0)
context.become(ProcessCommands)
case Tcp.PeerClosed =>
context.stop(self)
case default =>
log.error(s"Unexpected message $default")
}
/// Process all buffered commands and stash other ones
def ProcessCommands: Receive = {
case c: CommandCall =>
stash()
unstashAll()
context.become(ProcessCommand)
case default =>
stash()
unstashAll()
context.become(ReceiveCommand)
}
/// Process a single command
def ProcessCommand: Receive = {
case CommandCall(cmd, args) =>
val data = Map[String, Any]()
if (cmd == "help" || cmd == "?") {
if (args.size == 0) {
var resp = "PsAdmin command usage\n"
for ((command, info) <- PsAdminCommands.commands) {
resp += s"${command} - ${info.usage}\n"
}
data { "message" } = resp
} else {
if (PsAdminCommands.commands.contains(args(0))) {
val info = PsAdminCommands.commands { args(0) }
data { "message" } = s"${args(0)} - ${info.usage}"
} else {
data { "message" } = s"Unknown command ${args(0)}"
data { "error" } = true
}
}
sendLine(write(data.toMap))
} else if (PsAdminCommands.commands.contains(cmd)) {
val cmd_template = PsAdminCommands.commands { cmd }
cmd_template match {
case PsAdminCommands.Command(usage, handler) =>
context.actorOf(Props(handler, args, services))
case PsAdminCommands.CommandInternal(usage, handler) =>
val resp = handler(args)
resp match {
case CommandGoodResponse(msg, data) =>
data { "message" } = msg
sendLine(write(data.toMap))
case CommandErrorResponse(msg, data) =>
data { "message" } = msg
data { "error" } = true
sendLine(write(data.toMap))
}
context.become(ProcessCommands)
}
} else {
data { "message" } = "Unknown command"
data { "error" } = true
sendLine(write(data.toMap))
context.become(ProcessCommands)
}
case resp: CommandResponse =>
resp match {
case CommandGoodResponse(msg, data) =>
data { "message" } = msg
sendLine(write(data.toMap))
case CommandErrorResponse(msg, data) =>
data { "message" } = msg
data { "error" } = true
sendLine(write(data.toMap))
}
context.become(ProcessCommands)
context.stop(sender())
case default =>
stash()
unstashAll()
context.become(ProcessCommands)
}
def sendLine(line: String) = {
ByteVector.encodeUtf8(line + "\n") match {
case Left(e) =>
log.error(s"Message encoding failure: $e")
case Right(bv) =>
connection ! Tcp.Write(bv.toByteString)
}
}
}

View file

@ -0,0 +1,32 @@
package net.psforever.login.psadmin
import scala.collection.mutable
sealed trait CommandResponse
case class CommandGoodResponse(message: String, data: mutable.Map[String, Any]) extends CommandResponse
case class CommandErrorResponse(message: String, data: mutable.Map[String, Any]) extends CommandResponse
object PsAdminCommands {
import CmdInternal._
val commands: Map[String, CommandInfo] = Map(
"list_players" -> Command(
"""Return a list of players connected to the interstellar cluster.""",
classOf[CmdListPlayers]
),
"dump_config" -> CommandInternal("""Dumps entire running config.""", cmdDumpConfig),
"shutdown" -> Command("""Shuts down the server forcefully.""", classOf[CmdShutdown]),
"thread_dump" -> CommandInternal("""Returns all thread's stack traces.""", cmdThreadDump)
)
sealed trait CommandInfo {
def usage: String
}
/// A command with full access to the ActorSystem and WorldServer services.
/// Spawns an Actor to handle the request and the service queries
case class Command[T](usage: String, handler: Class[T]) extends CommandInfo
/// A command without access to the ActorSystem or any services
case class CommandInternal(usage: String, handler: ((Array[String]) => CommandResponse)) extends CommandInfo
}

View file

@ -0,0 +1,17 @@
package net.psforever.objects
import net.psforever.objects.zones.{Zone, Zoning}
import net.psforever.packet.game.DeadState
case class Session(
id: Long = 0,
zone: Zone = Zone.Nowhere,
account: Account = null,
player: Player = null,
avatar: Avatar = null,
admin: Boolean = false,
zoningType: Zoning.Method.Value = Zoning.Method.None,
deadState: DeadState.Value = DeadState.Alive,
speed: Float = 1.0f,
flying: Boolean = false
)

View file

@ -63,8 +63,8 @@ object ChatMessageType extends Enumeration {
CMT_GMTELL, // /gmtell (actually causes normal /tell 0x20 when not a gm???)
CMT_NOTE, // /note
CMT_GMBROADCASTPOPUP, // /gmpopup
U_CMT_GMTELLFROM, // ??? Recipient of /gmtell?
U_CMT_TELLFROM, // ??? Recipient of /t?
U_CMT_GMTELLFROM, // Acknowledgement of /gmtell for sender
U_CMT_TELLFROM, // Acknowledgement of /tell for sender
UNK_45, // ??? empty
CMT_CULLWATERMARK, // ??? This actually causes the client to ping back to the server with some stringified numbers "80 120" (with the same 46 chatmsg causing infinite loop?) - may be incorrect decoding
CMT_INSTANTACTION, // /instantaction OR /ia

View file

@ -0,0 +1,120 @@
package net.psforever.util
import java.nio.file.Paths
import com.typesafe.config.{Config => TypesafeConfig}
import enumeratum.values.{IntEnum, IntEnumEntry}
import net.psforever.packet.game.ServerType
import pureconfig.ConfigConvert.viaNonEmptyStringOpt
import pureconfig.ConfigReader.Result
import pureconfig.{ConfigConvert, ConfigSource}
import scala.concurrent.duration._
import scala.reflect.ClassTag
import pureconfig.generic.auto._ // intellij: this is not unused
object Config {
// prog.home is defined when we are running from SBT pack
val directory: String = System.getProperty("prog.home") match {
case null =>
Paths.get("config").toAbsolutePath.toString
case home =>
Paths.get(home, "config").toAbsolutePath.toString
}
implicit def enumeratumIntConfigConvert[A <: IntEnumEntry](implicit
enum: IntEnum[A],
ct: ClassTag[A]
): ConfigConvert[A] =
viaNonEmptyStringOpt[A](
v =>
enum.values.toList.collectFirst {
case (e: ServerType) if e.name == v => e.asInstanceOf[A]
},
_.value.toString
)
private val source = {
val configFile = Paths.get(directory, "psforever.conf").toFile()
if (configFile.exists)
ConfigSource.file(configFile).withFallback(ConfigSource.defaultApplication)
else
ConfigSource.defaultApplication
}
val result: Result[AppConfig] = source.load[AppConfig]
// Raw config object - prefer app when possible
lazy val config: TypesafeConfig = source.config().toOption.get
// Typed config object
lazy val app: AppConfig = result.toOption.get
}
case class AppConfig(
bind: String,
public: String,
login: LoginConfig,
world: WorldConfig,
admin: AdminConfig,
database: DatabaseConfig,
antiCheat: AntiCheatConfig,
network: NetworkConfig,
developer: DeveloperConfig,
kamon: KamonConfig
)
case class LoginConfig(
port: Int,
createMissingAccounts: Boolean
)
case class WorldConfig(
port: Int,
serverName: String,
serverType: ServerType
)
case class AdminConfig(
port: Int,
bind: String
)
case class DatabaseConfig(
host: String,
port: Int,
username: String,
password: String,
database: String,
sslmode: String
) {
def toJdbc = s"jdbc:postgresql://$host:$port/$database"
}
case class AntiCheatConfig(
hitPositionDiscrepancyThreshold: Int
)
case class NetworkConfig(
session: SessionConfig
)
case class SessionConfig(
inboundGraceTime: Duration,
outboundGraceTime: Duration
)
case class DeveloperConfig(
netSim: NetSimConfig
)
case class NetSimConfig(
enable: Boolean,
loss: Double,
delay: Duration,
reorderChance: Double,
reorderTime: Duration
)
case class KamonConfig(
enable: Boolean
)

View file

@ -0,0 +1,20 @@
package net.psforever.util
import io.getquill.{PostgresJAsyncContext, SnakeCase}
import net.psforever.persistence
object Database {
implicit val accountSchemaMeta = ctx.schemaMeta[persistence.Account]("accounts", _.id -> "id")
implicit val characterSchemaMeta = ctx.schemaMeta[persistence.Character]("characters", _.id -> "id")
implicit val loadoutSchemaMeta = ctx.schemaMeta[persistence.Loadout]("loadouts", _.id -> "id")
implicit val lockerSchemaMeta = ctx.schemaMeta[persistence.Locker]("lockers", _.id -> "id")
implicit val loginSchemaMeta = ctx.schemaMeta[persistence.Login]("logins", _.id -> "id")
// TODO remove if this gets merged https://github.com/getquill/quill/pull/1765
implicit class ILike(s1: String) {
import ctx._
def ilike(s2: String) = quote(infix"$s1 ilike $s2".as[Boolean])
}
val ctx = new PostgresJAsyncContext(SnakeCase, Config.config.getConfig("database"))
}

View file

@ -0,0 +1,581 @@
package net.psforever.util
import net.psforever.types.Vector3
import scala.collection.mutable
import scala.util.Random
/**
* A crude representation of the information needed to describe a continent (hitherto, a "zone").
* The information is mainly catered to the simulation of the CSR commands `/zone` and `/warp`.
* (The exception is `alias` which is maintained for cosmetic purposes and clarification.)
* @param alias the common name of the zone
* @param map the map name of the zone (this map is loaded)
* @param zonename the zone's internal name
*/
class PointOfInterest(val alias: String, val map: String, val zonename: String) {
/**
* A listing of warpgates, geowarps, and island warpgates in this zone.
* The coordinates specified will only ever drop the user on a specific point within the protective bubble of the warpgate.
* This breaks from the expected zoning functionality where the user is placed in a random spot under the bubble.
* There is no prior usage details for the searchability format of this field's key values.
*/
private val gates: mutable.HashMap[String, Vector3] = mutable.HashMap()
/**
* A listing of special locations in this zone, i.e., major faciities, and some landmarks of interest.
* There is no prior usage details for the searchability format of this field's key values.
*/
private val locations: mutable.HashMap[String, Vector3] = mutable.HashMap()
}
object PointOfInterest {
/**
* A listing of all zones that can be visited by their internal name.
* The keys in this map should be directly usable by the `/zone` command.
*/
private val zones = Map[String, PointOfInterest](
"z1" -> PointOfInterest("Solsar", "map01", "z1"),
"z2" -> PointOfInterest("Hossin", "map02", "z2"),
"z3" -> PointOfInterest("Cyssor", "map03", "z3"),
"z4" -> PointOfInterest("Ishundar", "map04", "z4"),
"z5" -> PointOfInterest("Forseral", "map05", "z5"),
"z6" -> PointOfInterest("Ceryshen", "map06", "z6"),
"z7" -> PointOfInterest("Esamir", "map07", "z7"),
"z8" -> PointOfInterest("Oshur", "map08", "z8"),
"z9" -> PointOfInterest("Searhus", "map09", "z9"),
"z10" -> PointOfInterest("Amerish", "map10", "z10"),
"home1" -> PointOfInterest("NC Sanctuary", "map11", "home1"),
"home2" -> PointOfInterest("TR Sanctuary", "map12", "home2"),
"home3" -> PointOfInterest("VS Sanctuary", "map13", "home3"),
"tzshtr" -> PointOfInterest("VR Shooting Range TR", "map14", "tzshtr"),
"tzdrtr" -> PointOfInterest("VR Driving Range TR", "map15", "tzdrtr"),
"tzcotr" -> PointOfInterest("VR Combat Zone TR", "map16", "tzcotr"),
"tzshvs" -> PointOfInterest("VR Shooting Range VS", "map14", "tzshvs"),
"tzdrvs" -> PointOfInterest("VR Driving Range VS", "map15", "tzdrvs"),
"tzcovs" -> PointOfInterest("VR Combat Zone VS", "map16", "tzcovs"),
"tzshnc" -> PointOfInterest("VR Shooting Range NC", "map14", "tzshnc"),
"tzdrnc" -> PointOfInterest("VR Driving Range NC", "map15", "tzdrnc"),
"tzconc" -> PointOfInterest("VR Combat Zone NC", "map16", "tzconc"),
"c1" -> PointOfInterest("Supai", "ugd01", "c1"),
"c2" -> PointOfInterest("Hunhau", "ugd02", "c2"),
"c3" -> PointOfInterest("Adlivun", "ugd03", "c3"),
"c4" -> PointOfInterest("Byblos", "ugd04", "c4"),
"c5" -> PointOfInterest("Annwn", "ugd05", "c5"),
"c6" -> PointOfInterest("Drugaskan", "ugd06", "c6"),
"i4" -> PointOfInterest("Nexus", "map96", "i4"),
"i3" -> PointOfInterest("Desolation", "map97", "i3"),
"i2" -> PointOfInterest("Ascension", "map98", "i2"),
"i1" -> PointOfInterest("Extinction", "map99", "i1"),
"homebo" -> PointOfInterest("Black_ops_hq", "Black_ops_hq", "homebo"),
"station1" -> PointOfInterest("TR Station", "Station1", "station1"),
"station2" -> PointOfInterest("NC Station", "Station2", "station2"),
"station3" -> PointOfInterest("VS Station", "Station3", "station3")
)
/**
* A listing of all zones that can be visited by their common name.
* The keys in this map should be directly usable by the `/zone` command.
* Though the behavior is undocumented, access to this alias list is for the benefit of the user.
*/
private val alias = Map[String, String](
"solsar" -> "z1",
"hossin" -> "z2",
"cyssor" -> "z3",
"ishundar" -> "z4",
"forseral" -> "z5",
"ceryshen" -> "z6",
"esamir" -> "z7",
"oshur" -> "z8",
"searhus" -> "z9",
"amerish" -> "z10",
"nc-sanctuary" -> "home1",
"tr-sanctuary" -> "home2",
"vs-sanctuary" -> "home3",
"tr-shooting" -> "tzshtr",
"tr-driving" -> "tzdrtr",
"tr-combat" -> "tzcotr",
"vs-shooting" -> "tzshvs",
"vs-driving" -> "tzdrvs",
"vs-combat" -> "tzcovs",
"nc-shooting" -> "tzshnc",
"nc-driving" -> "tzdrnc",
"nc-combat" -> "tzconc",
"supai" -> "c1",
"hunhau" -> "c2",
"adlivun" -> "c3",
"byblos" -> "c4",
"annwn" -> "c5",
"drugaskan" -> "c6",
"nexus" -> "i4",
"desolation" -> "i3",
"ascension" -> "i2",
"extinction" -> "i1",
"Black_ops_hq" -> "homebo",
"TR-Station" -> "station1",
"NC-Station" -> "station2",
"VS-Station" -> "station3"
)
/**
* A value used for selecting where to appear in a zone from the list of locations when the user has no indicated one.
*/
private val rand = Random
setup()
/**
* An abbreviated constructor for creating `CSRZone`s without invocation of `new`.
* @param alias the common name of the zone
* @param map the map name of the zone (this map is loaded)
* @param zonename the zone's internal name
*/
def apply(alias: String, map: String, zonename: String): PointOfInterest = new PointOfInterest(alias, map, zonename)
/**
* Get a valid `CSRZone`'s information.
* @param zoneId a name that describes the zone and should be searchable
* @return the `CSRZone`, or `None`
*/
def get(zoneId: String): Option[PointOfInterest] = {
var zId = zoneId.toLowerCase
if (alias.get(zId).isDefined)
zId = alias(zId)
zones.get(zId)
}
/**
* Get a location within the `CSRZone`.
* The location should be a facility or a warpgate or interesting.
* @param zoneId the `CSRZone`
* @param locationId a name that describes a known location in the provided `CSRZone` and is searchable
* @return the coordinates of that location, or None
*/
def getWarpLocation(zoneId: String, locationId: String): Option[Vector3] = {
get(zoneId) match {
case Some(poi) =>
poi.locations.get(locationId) match {
case Some(position) => Some(position)
case None => poi.gates.get(locationId)
}
case None => None
}
}
/**
* Get the position of a warpgate within the zone.
* @param zone the `CSRZone`
* @param gateId a name that describes a known warpgate in the provided `CSRZone` and is searchable
* @return the coordinates of that warpgate, or None
*/
def getWarpgate(zone: PointOfInterest, gateId: String): Option[Vector3] = {
zone.gates.get(gateId.toLowerCase)
}
/**
* Get the names for all of the `CSRZones` that can be visited.
* @return all of the zonenames
*/
def list: String = {
"zone names: z1 - z10, home1 - home3, tzshnc, tzdrnc, tzconc, tzshtr, tzdrtr, tzcotr, tzshvs, tzdrvs, tzcovs, c1 - c6, i1 - i4; zones are also aliased to their continent name"
}
/**
* Get the name for all of the locations that can be visited in this `CSRZone`, excluding warpgates.
* @param zone the `CSRZone`
* @return all of the location keys
*/
def listLocations(zone: PointOfInterest): String = {
var out: String = "warps: "
if (zone.locations.nonEmpty) {
out += zone.locations.keys.toArray.sorted.mkString(", ")
} else
out = "none"
out
}
/**
* Get the name for all of the warpgates that can be visited in this `CSRZone`.
* @param zone the `CSRZone`
* @return all of the warpgate keys
*/
def listWarpgates(zone: PointOfInterest): String = {
var out: String = "gatenames: "
if (zone.gates.isEmpty)
out += "none"
else
out += zone.gates.keys.toArray.sorted.mkString(", ")
out
}
/**
* Select, of all the `CSRZone` locations and warpgates, a pseudorandom destination to spawn the player in the zone if none has been specified.
* @param zone the `CSRZone`
* @return the coordinates of the spawn point
*/
def selectRandom(zone: PointOfInterest): Vector3 = {
var outlets = zone.locations //random location?
if (outlets.nonEmpty) {
return outlets.values.toArray.apply(rand.nextInt(outlets.size))
}
outlets = zone.gates //random warpgate?
if (outlets.nonEmpty) {
return outlets.values.toArray.apply(rand.nextInt(outlets.size))
}
Vector3.Zero //fallback coordinates (that will always be valid)
}
/**
* Load all zones with selected places of interest and the coordinates to place the player nearby that given place of interest.
* All of these keys should be searchable under the `/warp` command.
* Only the warpgate keys are searchable by the `/zone` command.
*/
def setup(): Unit = {
zones("z1").gates ++= Map(
"gate1" -> Vector3(4150, 7341, 82),
"gate2" -> Vector3(5698, 3404, 129),
"gate3" -> Vector3(2650, 5363, 176),
"gate4" -> Vector3(3022, 1225, 66),
"geowarp1" -> Vector3(3678, 2895, 108),
"geowarp2" -> Vector3(5672, 4750, 70)
)
zones("z1").locations ++= Map(
"amun" -> Vector3(4337, 2278, 68),
"aton" -> Vector3(3772, 5463, 54),
"bastet" -> Vector3(5412, 5588, 56),
"hapi" -> Vector3(4256, 4436, 59),
"horus" -> Vector3(3725, 2114, 73),
"mont" -> Vector3(3354, 4205, 83),
"seth" -> Vector3(4495, 6026, 58),
"sobek" -> Vector3(3094, 3027, 75),
"thoth" -> Vector3(4615, 3373, 53),
"lake" -> Vector3(4317, 4008, 37),
"monolith" -> Vector3(5551, 5047, 64)
)
zones("z2").gates ++= Map(
"gate1" -> Vector3(1881, 4873, 19),
"gate2" -> Vector3(4648, 4625, 28),
"gate3" -> Vector3(3296, 2045, 21),
"gate4" -> Vector3(5614, 1781, 32),
"geowarp1" -> Vector3(5199, 4869, 39),
"geowarp2" -> Vector3(3911, 2407, 15)
)
zones("z2").locations ++= Map(
"acan" -> Vector3(3534, 4015, 30),
"bitol" -> Vector3(4525, 2632, 30),
"chac" -> Vector3(4111, 5950, 39),
"ghanon" -> Vector3(2565, 3707, 41),
"hurakan" -> Vector3(1840, 2934, 38),
"ixtab" -> Vector3(3478, 3143, 40),
"kisin" -> Vector3(3356, 5374, 31),
"mulac" -> Vector3(5592, 2738, 37),
"naum" -> Vector3(5390, 3454, 28),
"voltan" -> Vector3(4529, 3414, 28),
"zotz" -> Vector3(6677, 2342, 129),
"monolith" -> Vector3(2938, 2485, 14)
)
zones("z3").gates ++= Map(
"gate1" -> Vector3(2616, 6567, 58),
"gate2" -> Vector3(6980, 5336, 57),
"gate3" -> Vector3(1199, 1332, 66),
"gate4" -> Vector3(5815, 1974, 63),
"geowarp1" -> Vector3(2403, 4278, 60),
"geowarp2" -> Vector3(4722, 2665, 78)
)
zones("z3").locations ++= Map(
"aja" -> Vector3(754, 5435, 48),
"chuku" -> Vector3(4208, 7021, 54),
"bomazi" -> Vector3(1198, 4492, 58),
"ekera" -> Vector3(5719, 6555, 51),
"faro" -> Vector3(5030, 5700, 57),
"gunuku" -> Vector3(4994, 4286, 54),
"honsi" -> Vector3(4042, 4588, 89),
"itan" -> Vector3(5175, 3393, 48),
"kaang" -> Vector3(5813, 3862, 62),
"leza" -> Vector3(2691, 1561, 64),
"mukuru" -> Vector3(661, 2380, 54),
"nzame" -> Vector3(1670, 2706, 45),
"orisha" -> Vector3(7060, 1327, 59),
"pamba" -> Vector3(7403, 3123, 63),
"shango" -> Vector3(6846, 2319, 63),
"tore" -> Vector3(3017, 2272, 58),
"wele" -> Vector3(436, 7040, 60),
"monolith" -> Vector3(4515, 4105, 38),
"peak" -> Vector3(3215, 5063, 579)
)
zones("z4").gates ++= Map(
"gate1" -> Vector3(4702, 6768, 30),
"gate2" -> Vector3(5515, 3368, 69),
"gate3" -> Vector3(1564, 3356, 46),
"gate4" -> Vector3(3889, 1118, 56),
"geowarp1" -> Vector3(4202, 4325, 68),
"geowarp2" -> Vector3(2384, 1925, 37)
)
zones("z4").locations ++= Map(
"akkan" -> Vector3(2746, 4260, 39),
"baal" -> Vector3(825, 5470, 72),
"dagon" -> Vector3(1739, 5681, 40),
"enkidu" -> Vector3(3217, 3574, 37),
"girru" -> Vector3(4475, 5853, 78),
"hanish" -> Vector3(3794, 5540, 89),
"irkalla" -> Vector3(4742, 5270, 66),
"kusag" -> Vector3(6532, 4692, 46),
"lahar" -> Vector3(6965, 5306, 38),
"marduk" -> Vector3(3059, 2144, 70),
"neti" -> Vector3(3966, 2417, 80),
"zaqar" -> Vector3(4796, 2177, 75),
"monolith" -> Vector3(5165, 4083, 35),
"stonehenge" -> Vector3(4992, 3776, 56)
)
zones("z5").gates ++= Map(
"gate1" -> Vector3(3432, 6498, 73),
"gate2" -> Vector3(7196, 3917, 47),
"gate3" -> Vector3(1533, 3540, 56),
"gate4" -> Vector3(3197, 1390, 45),
"geowarp1" -> Vector3(4899, 5633, 38),
"geowarp2" -> Vector3(5326, 2558, 54)
)
zones("z5").locations ++= Map(
"anu" -> Vector3(3479, 2556, 56),
"bel" -> Vector3(3665, 4626, 58),
"caer" -> Vector3(4570, 2601, 56),
"dagda" -> Vector3(5825, 4449, 55),
"eadon" -> Vector3(2725, 2853, 53),
"gwydion" -> Vector3(5566, 3739, 61),
"lugh" -> Vector3(6083, 5069, 72),
"neit" -> Vector3(4345, 4319, 76),
"ogma" -> Vector3(3588, 3227, 114),
"pwyll" -> Vector3(4683, 4764, 104),
"monolith" -> Vector3(3251, 3245, 160),
"islands1" -> Vector3(6680, 6217, 125),
"islands2" -> Vector3(1059, 6213, 120)
)
zones("z6").gates ++= Map(
"gate1" -> Vector3(5040, 4327, 46),
"gate2" -> Vector3(2187, 5338, 30),
"gate3" -> Vector3(4960, 1922, 15),
"gate4" -> Vector3(2464, 3088, 189),
"geowarp1" -> Vector3(3221, 5328, 242),
"geowarp2" -> Vector3(2237, 1783, 238)
)
zones("z6").locations ++= Map(
"akna" -> Vector3(4509, 3732, 219),
"anguta" -> Vector3(3999, 4170, 266),
"igaluk" -> Vector3(3241, 5658, 235),
"keelut" -> Vector3(3630, 1904, 265),
"nerrivik" -> Vector3(3522, 3703, 322),
"pinga" -> Vector3(5938, 3545, 96),
"sedna" -> Vector3(3932, 5160, 232),
"tarqaq" -> Vector3(2980, 2155, 237),
"tootega" -> Vector3(5171, 3251, 217),
"monolith" -> Vector3(4011, 4851, 32),
"bridge" -> Vector3(3729, 4859, 234)
)
zones("z7").gates ++= Map(
"gate1" -> Vector3(1516, 6448, 61),
"gate2" -> Vector3(5249, 3819, 69),
"gate3" -> Vector3(2763, 2961, 86),
"gate4" -> Vector3(6224, 1152, 78),
"geowarp1" -> Vector3(6345, 4802, 90),
"geowarp2" -> Vector3(3800, 2197, 64)
)
zones("z7").locations ++= Map(
"andvari" -> Vector3(3233, 7207, 78),
"dagur" -> Vector3(4026, 6191, 60),
"eisa" -> Vector3(3456, 4513, 75),
"freyr" -> Vector3(2853, 3840, 56),
"gjallar" -> Vector3(1056, 2656, 74),
"helheim" -> Vector3(5542, 2532, 53),
"jarl" -> Vector3(1960, 5462, 68),
"kvasir" -> Vector3(4096, 1571, 69),
"mani" -> Vector3(5057, 4989, 58),
"nott" -> Vector3(6783, 4329, 46),
"ran" -> Vector3(2378, 1919, 85),
"vidar" -> Vector3(3772, 3024, 67),
"ymir" -> Vector3(1911, 4008, 69),
"monolith" -> Vector3(6390, 1622, 63)
)
zones("z8").gates ++= Map(
"gate1" -> Vector3(5437, 5272, 32),
"gate2" -> Vector3(3251, 5650, 60),
"gate3" -> Vector3(5112, 2616, 40),
"gate4" -> Vector3(2666, 1665, 45),
"geowarp1" -> Vector3(3979, 5370, 47),
"geowarp2" -> Vector3(6018, 3136, 35)
)
zones("z8").locations ++= Map(
"atar" -> Vector3(3609, 2730, 47),
"dahaka" -> Vector3(4633, 5379, 54),
"hvar" -> Vector3(3857, 4764, 49),
"izha" -> Vector3(5396, 3852, 51),
"jamshid" -> Vector3(2371, 3378, 52),
"mithra" -> Vector3(2480, 4456, 44),
"rashnu" -> Vector3(3098, 3961, 59),
"yazata" -> Vector3(4620, 3983, 62),
"zal" -> Vector3(3966, 2164, 61),
"arch1" -> Vector3(4152, 3285, 31),
"arch2" -> Vector3(4688, 5272, 68),
"pride" -> Vector3(2913, 4412, 63)
)
zones("z9").gates ++= Map(
"gate1" -> Vector3(1505, 6981, 65),
"gate2" -> Vector3(6835, 3517, 56),
"gate3" -> Vector3(1393, 1376, 53),
"geowarp1" -> Vector3(7081, 5552, 46),
"geowarp2" -> Vector3(3776, 1092, 49)
)
zones("z9").locations ++= Map(
"akua" -> Vector3(5258, 4041, 346),
"drakulu" -> Vector3(3806, 2647, 151),
"hiro" -> Vector3(4618, 5761, 190),
"iva" -> Vector3(6387, 5199, 55),
"karihi" -> Vector3(3879, 5574, 236),
"laka" -> Vector3(4720, 6718, 49),
"matagi" -> Vector3(5308, 5093, 239),
"ngaru" -> Vector3(4103, 4077, 205),
"oro" -> Vector3(4849, 4456, 208),
"pele" -> Vector3(4549, 3712, 208),
"rehua" -> Vector3(3843, 2195, 60),
"sina" -> Vector3(5919, 2177, 91),
"tara" -> Vector3(1082, 4225, 60),
"wakea" -> Vector3(1785, 5241, 63),
"monolith" -> Vector3(3246, 6507, 105)
)
zones("z10").gates ++= Map(
"gate1" -> Vector3(6140, 6599, 71),
"gate2" -> Vector3(4814, 4608, 59),
"gate3" -> Vector3(3152, 3480, 54),
"gate4" -> Vector3(1605, 1446, 40),
"geowarp1" -> Vector3(3612, 6918, 38),
"geowarp2" -> Vector3(3668, 3327, 55)
)
zones("z10").locations ++= Map(
"azeban" -> Vector3(6316, 5160, 62),
"cetan" -> Vector3(3587, 2522, 48),
"heyoka" -> Vector3(4395, 2327, 47),
"ikanam" -> Vector3(2740, 2412, 57),
"kyoi" -> Vector3(5491, 2284, 62),
"mekala" -> Vector3(6087, 2925, 59),
"onatha" -> Vector3(3397, 5799, 48),
"qumu" -> Vector3(3990, 5152, 46),
"sungrey" -> Vector3(4609, 5624, 72),
"tumas" -> Vector3(4687, 6392, 69),
"verica" -> Vector3(4973, 3459, 47),
"xelas" -> Vector3(6609, 4479, 56),
"monolith" -> Vector3(5651, 6024, 38)
)
zones("home1").gates ++= Map(
"gate1" -> Vector3(4158, 6344, 44),
"gate2" -> Vector3(2214, 5797, 48),
"gate3" -> Vector3(5032, 3241, 53)
)
zones("home1").locations += "hart_c" -> Vector3(2352, 5523, 66)
zones("home2").gates ++= Map(
"gate1" -> Vector3(5283, 4317, 44),
"gate2" -> Vector3(3139, 4809, 40),
"gate3" -> Vector3(3659, 2894, 26)
)
zones("home2").locations += "hart_c" -> Vector3(3125, 2864, 35)
zones("home3").gates ++= Map(
"gate1" -> Vector3(5657, 4681, 98),
"gate2" -> Vector3(2639, 5366, 57),
"gate3" -> Vector3(4079, 2467, 155)
)
zones("home3").locations += "hart_c" -> Vector3(3675, 2727, 91)
zones("tzshtr").locations += "roof" -> Vector3(499, 1568, 25)
zones("tzcotr").locations += "spawn" -> Vector3(960, 1002, 32)
zones("tzdrtr").locations ++= Map(
"start" -> Vector3(2457, 1864, 23),
"air_pad" -> Vector3(1700, 1900, 32)
)
zones("tzshvs").locations += "roof" -> Vector3(499, 1568, 25)
zones("tzcovs").locations += "spawn" -> Vector3(960, 1002, 32)
zones("tzdrvs").locations ++= Map(
"start" -> Vector3(2457, 1864, 23),
"air_pad" -> Vector3(1700, 1900, 32)
)
zones("tzshnc").locations += "roof" -> Vector3(499, 1568, 25)
zones("tzconc").locations += "spawn" -> Vector3(960, 1002, 32)
zones("tzdrnc").locations ++= Map(
"start" -> Vector3(2457, 1864, 23),
"air_pad" -> Vector3(1700, 1900, 32)
)
zones("c1").gates ++= Map(
"geowarp1" -> Vector3(998, 2038, 103),
"geowarp2" -> Vector3(231, 1026, 82),
"geowarp3" -> Vector3(2071, 1405, 102),
"geowarp4" -> Vector3(1051, 370, 103)
)
zones("c2").gates ++= Map(
"geowarp1" -> Vector3(999, 2386, 243),
"geowarp2" -> Vector3(283, 1249, 172),
"geowarp3" -> Vector3(1887, 1307, 192),
"geowarp4" -> Vector3(1039, 155, 143)
)
zones("c3").gates ++= Map(
"geowarp1" -> Vector3(1095, 1725, 25),
"geowarp2" -> Vector3(226, 832, 42),
"geowarp3" -> Vector3(1832, 1026, 43),
"geowarp4" -> Vector3(981, 320, 46)
)
zones("c4").gates ++= Map(
"geowarp1" -> Vector3(902, 1811, 93),
"geowarp2" -> Vector3(185, 922, 113),
"geowarp3" -> Vector3(1696, 1188, 92),
"geowarp4" -> Vector3(887, 227, 115)
)
zones("c5").gates ++= Map(
"geowarp1" -> Vector3(1195, 1752, 244),
"geowarp2" -> Vector3(290, 1104, 235),
"geowarp3" -> Vector3(1803, 899, 243),
"geowarp4" -> Vector3(1042, 225, 246)
)
zones("c6").gates ++= Map(
"geowarp1" -> Vector3(1067, 2044, 95),
"geowarp2" -> Vector3(290, 693, 73),
"geowarp3" -> Vector3(1922, 928, 33),
"geowarp4" -> Vector3(1174, 249, 114)
)
zones("i3").gates ++= Map(
"gate1" -> Vector3(1219, 2580, 30),
"gate2" -> Vector3(2889, 2919, 33),
"gate3" -> Vector3(2886, 1235, 32)
)
zones("i3").locations ++= Map(
"dahaka" -> Vector3(1421, 2216, 30),
"jamshid" -> Vector3(2500, 2543, 30),
"izha" -> Vector3(2569, 1544, 30),
"oasis" -> Vector3(2084, 1935, 40)
)
zones("i2").gates ++= Map(
"gate1" -> Vector3(1243, 1393, 12),
"gate2" -> Vector3(2510, 2544, 12),
"gate3" -> Vector3(2634, 1477, 12)
)
zones("i2").locations ++= Map(
"rashnu" -> Vector3(1709, 1802, 91),
"sraosha" -> Vector3(2729, 2349, 91),
"zal" -> Vector3(1888, 2728, 91),
"center" -> Vector3(2082, 2192, 160),
"vpad" -> Vector3(1770, 2686, 92)
)
zones("i1").gates ++= Map(
"gate1" -> Vector3(1225, 2036, 67),
"gate2" -> Vector3(2548, 2801, 65),
"gate3" -> Vector3(2481, 1194, 89)
)
zones("i1").locations ++= Map(
"hvar" -> Vector3(1559, 1268, 88),
"mithra" -> Vector3(2855, 2850, 89),
"yazata" -> Vector3(1254, 2583, 88),
"south_of_volcano" -> Vector3(2068, 1686, 88)
)
zones("i4").gates ++= Map(
"gate1" -> Vector3(2359, 2717, 36),
"gate2" -> Vector3(2732, 1355, 36),
"geowarp" -> Vector3(1424, 1640, 45)
)
zones("i4").locations += "atar" -> Vector3(1915, 1936, 43)
}
}

View file

@ -0,0 +1,65 @@
package net.psforever.zones
import net.psforever.objects.LocalProjectile
import net.psforever.objects.ballistics.Projectile
import net.psforever.objects.zones.ZoneMap
import net.psforever.zones.zonemaps._
import scala.concurrent.Future
import scala.util.{Failure, Success}
object Maps {
import scala.concurrent.ExecutionContext.Implicits.global
val map01 = InitZoneMap(Future { Map01.ZoneMap })
val map02 = InitZoneMap(Future { Map02.ZoneMap })
val map03 = InitZoneMap(Future { Map03.ZoneMap })
val map04 = InitZoneMap(Future { Map04.ZoneMap })
val map05 = InitZoneMap(Future { Map05.ZoneMap })
val map06 = InitZoneMap(Future { Map06.ZoneMap })
val map07 = InitZoneMap(Future { Map07.ZoneMap })
val map08 = InitZoneMap(Future { Map08.ZoneMap })
val map09 = InitZoneMap(Future { Map09.ZoneMap })
val map10 = InitZoneMap(Future { Map10.ZoneMap })
val map11 = InitZoneMap(Future { Map11.ZoneMap })
val map12 = InitZoneMap(Future { Map12.ZoneMap })
val map13 = InitZoneMap(Future { Map13.ZoneMap })
val map14 = new ZoneMap("map14") {
Projectiles(this)
}
val map15 = new ZoneMap("map15") {
Projectiles(this)
}
val map16 = new ZoneMap("map16") {
Projectiles(this)
}
val ugd01 = InitZoneMap(Future { Ugd01.ZoneMap })
val ugd02 = InitZoneMap(Future { Ugd02.ZoneMap })
val ugd03 = InitZoneMap(Future { Ugd03.ZoneMap })
val ugd04 = InitZoneMap(Future { Ugd04.ZoneMap })
val ugd05 = InitZoneMap(Future { Ugd05.ZoneMap })
val ugd06 = InitZoneMap(Future { Ugd06.ZoneMap })
val map96 = InitZoneMap(Future { Map96.ZoneMap })
val map97 = InitZoneMap(Future { Map97.ZoneMap })
val map98 = InitZoneMap(Future { Map98.ZoneMap })
val map99 = InitZoneMap(Future { Map99.ZoneMap })
def Projectiles(zmap: ZoneMap): Unit = {
(Projectile.BaseUID until Projectile.RangeUID) foreach {
zmap.LocalObject(_, LocalProjectile.Constructor)
}
}
def InitZoneMap(future: Future[ZoneMap]): Future[ZoneMap] = {
future onComplete {
case Success(x) => Projectiles(x)
case Failure(_) => throw new RuntimeException("Maps: failure when setting up map") //should not fail?
}
future
}
}

View file

@ -0,0 +1,572 @@
package net.psforever.zones
import akka.actor.ActorContext
import net.psforever.objects.GlobalDefinitions
import net.psforever.objects.serverobject.resourcesilo.ResourceSilo
import net.psforever.objects.serverobject.structures.WarpGate
import net.psforever.objects.zones.Zone
import net.psforever.types.PlanetSideEmpire
import scala.concurrent.duration._
import scala.collection.immutable.HashMap
import scala.concurrent.Await
object Zones {
val zones: HashMap[String, Zone] = HashMap(
(
"z1",
new Zone("z1", Await.result(Maps.map01, 30 seconds), 1) {
override def Init(implicit context: ActorContext): Unit = {
super.Init(context)
HotSpotCoordinateFunction = Zones.HotSpots.StandardRemapping(Map.Scale, 80, 80)
HotSpotTimeFunction = Zones.HotSpots.StandardTimeRules
InitZoneAmenities(zone = this)
}
}
),
(
"z2",
new Zone("z2", Await.result(Maps.map02, 30 seconds), 2) {
override def Init(implicit context: ActorContext): Unit = {
super.Init(context)
HotSpotCoordinateFunction = Zones.HotSpots.StandardRemapping(Map.Scale, 80, 80)
HotSpotTimeFunction = Zones.HotSpots.StandardTimeRules
InitZoneAmenities(zone = this)
}
}
),
(
"z3",
new Zone("z3", Await.result(Maps.map03, 30 seconds), 3) {
override def Init(implicit context: ActorContext): Unit = {
super.Init(context)
HotSpotCoordinateFunction = Zones.HotSpots.StandardRemapping(Map.Scale, 80, 80)
HotSpotTimeFunction = Zones.HotSpots.StandardTimeRules
InitZoneAmenities(zone = this)
}
}
),
(
"z4",
new Zone("z4", Await.result(Maps.map04, 30 seconds), 4) {
override def Init(implicit context: ActorContext): Unit = {
super.Init(context)
HotSpotCoordinateFunction = Zones.HotSpots.StandardRemapping(Map.Scale, 80, 80)
HotSpotTimeFunction = Zones.HotSpots.StandardTimeRules
InitZoneAmenities(zone = this)
BuildingByMapId(5).get.Faction = PlanetSideEmpire.TR //Akkan
BuildingByMapId(6).get.Faction = PlanetSideEmpire.TR //Baal
BuildingByMapId(7).get.Faction = PlanetSideEmpire.TR //Dagon
BuildingByMapId(8).get.Faction = PlanetSideEmpire.NC //Enkidu
BuildingByMapId(9).get.Faction = PlanetSideEmpire.VS //Girru
BuildingByMapId(10).get.Faction = PlanetSideEmpire.VS //Hanish
BuildingByMapId(11).get.Faction = PlanetSideEmpire.VS //Irkalla
BuildingByMapId(12).get.Faction = PlanetSideEmpire.VS //Kusag
BuildingByMapId(13).get.Faction = PlanetSideEmpire.VS //Lahar
BuildingByMapId(14).get.Faction = PlanetSideEmpire.NC //Marduk
BuildingByMapId(15).get.Faction = PlanetSideEmpire.NC //Neti
BuildingByMapId(16).get.Faction = PlanetSideEmpire.NC //Zaqar
BuildingByMapId(17).get.Faction = PlanetSideEmpire.NC //S_Marduk_Tower
BuildingByMapId(18).get.Faction = PlanetSideEmpire.NC //W_Neti_Tower
BuildingByMapId(19).get.Faction = PlanetSideEmpire.NC //W_Zaqar_Tower
BuildingByMapId(20).get.Faction = PlanetSideEmpire.NC //E_Zaqar_Tower
BuildingByMapId(21).get.Faction = PlanetSideEmpire.NC //NE_Neti_Tower
BuildingByMapId(22).get.Faction = PlanetSideEmpire.NC //SE_Ceryshen_Warpgate_Tower
BuildingByMapId(23).get.Faction = PlanetSideEmpire.VS //S_Kusag_Tower
BuildingByMapId(24).get.Faction = PlanetSideEmpire.VS //NW_Kusag_Tower
BuildingByMapId(25).get.Faction = PlanetSideEmpire.VS //N_Ceryshen_Warpgate_Tower
BuildingByMapId(26).get.Faction = PlanetSideEmpire.VS //SE_Irkalla_Tower
BuildingByMapId(27).get.Faction = PlanetSideEmpire.VS //S_Irkalla_Tower
BuildingByMapId(28).get.Faction = PlanetSideEmpire.TR //NE_Enkidu_Tower
BuildingByMapId(29).get.Faction = PlanetSideEmpire.NC //SE_Akkan_Tower
BuildingByMapId(30).get.Faction = PlanetSideEmpire.NC //SW_Enkidu_Tower
BuildingByMapId(31).get.Faction = PlanetSideEmpire.TR //E_Searhus_Warpgate_Tower
BuildingByMapId(32).get.Faction = PlanetSideEmpire.TR //N_Searhus_Warpgate_Tower
BuildingByMapId(33).get.Faction = PlanetSideEmpire.VS //E_Girru_Tower
BuildingByMapId(34).get.Faction = PlanetSideEmpire.VS //SE_Hanish_Tower
BuildingByMapId(35).get.Faction = PlanetSideEmpire.TR //SW_Hanish_Tower
BuildingByMapId(36).get.Faction = PlanetSideEmpire.VS //W_Girru_Tower
BuildingByMapId(37).get.Faction = PlanetSideEmpire.TR //E_Dagon_Tower
BuildingByMapId(38).get.Faction = PlanetSideEmpire.TR //NE_Baal_Tower
BuildingByMapId(39).get.Faction = PlanetSideEmpire.TR //SE_Baal_Tower
BuildingByMapId(40).get.Faction = PlanetSideEmpire.TR //S_Dagon_Tower
BuildingByMapId(41).get.Faction = PlanetSideEmpire.NC //W_Ceryshen_Warpgate_Tower
BuildingByMapId(42).get.Faction = PlanetSideEmpire.NEUTRAL //dagon bunker
BuildingByMapId(43).get.Faction = PlanetSideEmpire.NEUTRAL //Akkan North Bunker
BuildingByMapId(44).get.Faction = PlanetSideEmpire.NEUTRAL //Enkidu East Bunker
BuildingByMapId(45).get.Faction = PlanetSideEmpire.NEUTRAL //Neti bunker
BuildingByMapId(46).get.Faction = PlanetSideEmpire.NEUTRAL //Hanish West Bunker
BuildingByMapId(47).get.Faction = PlanetSideEmpire.NEUTRAL //Irkalla East Bunker
BuildingByMapId(48).get.Faction = PlanetSideEmpire.NEUTRAL //Zaqar bunker
BuildingByMapId(49).get.Faction = PlanetSideEmpire.NEUTRAL //Kusag West Bunker
BuildingByMapId(50).get.Faction = PlanetSideEmpire.NEUTRAL //marduk bunker
BuildingByMapId(51).get.Faction = PlanetSideEmpire.TR //baal bunker
BuildingByMapId(52).get.Faction = PlanetSideEmpire.NEUTRAL //girru bunker
BuildingByMapId(53).get.Faction = PlanetSideEmpire.NEUTRAL //lahar bunker
BuildingByMapId(54).get.Faction = PlanetSideEmpire.NEUTRAL //akkan bunker
BuildingByMapId(55).get.Faction = PlanetSideEmpire.VS //Irkalla_Tower
BuildingByMapId(56).get.Faction = PlanetSideEmpire.VS //Hanish_Tower
BuildingByMapId(57).get.Faction = PlanetSideEmpire.VS //E_Ceryshen_Warpgate_Tower
BuildingByMapId(58).get.Faction = PlanetSideEmpire.VS //Lahar_Tower
BuildingByMapId(59).get.Faction = PlanetSideEmpire.VS //VSSanc_Warpgate_Tower
BuildingByMapId(60).get.Faction = PlanetSideEmpire.TR //Akkan_Tower
BuildingByMapId(61).get.Faction = PlanetSideEmpire.NC //TRSanc_Warpgate_Tower
BuildingByMapId(62).get.Faction = PlanetSideEmpire.NC //Marduk_Tower
BuildingByMapId(63).get.Faction = PlanetSideEmpire.TR //NW_Dagon_Tower
BuildingByMapId(64).get.Faction = PlanetSideEmpire.NEUTRAL //E7 East Bunker (at north from bridge)
BuildingByMapId(65).get.Faction = PlanetSideEmpire.VS //W_Hanish_Tower
}
}
),
(
"z5",
new Zone("z5", Await.result(Maps.map05, 30 seconds), 5) {
override def Init(implicit context: ActorContext): Unit = {
super.Init(context)
HotSpotCoordinateFunction = Zones.HotSpots.StandardRemapping(Map.Scale, 80, 80)
HotSpotTimeFunction = Zones.HotSpots.StandardTimeRules
InitZoneAmenities(zone = this)
}
}
),
(
"z6",
new Zone("z6", Await.result(Maps.map06, 30 seconds), 6) {
override def Init(implicit context: ActorContext): Unit = {
super.Init(context)
HotSpotCoordinateFunction = Zones.HotSpots.StandardRemapping(Map.Scale, 80, 80)
HotSpotTimeFunction = Zones.HotSpots.StandardTimeRules
import net.psforever.types.PlanetSideEmpire
BuildingByMapId(2).get.Faction = PlanetSideEmpire.VS
BuildingByMapId(48).get.Faction = PlanetSideEmpire.VS
BuildingByMapId(49).get.Faction = PlanetSideEmpire.VS
InitZoneAmenities(zone = this)
}
}
),
(
"z7",
new Zone("z7", Await.result(Maps.map07, 30 seconds), 7) {
override def Init(implicit context: ActorContext): Unit = {
super.Init(context)
HotSpotCoordinateFunction = Zones.HotSpots.StandardRemapping(Map.Scale, 80, 80)
HotSpotTimeFunction = Zones.HotSpots.StandardTimeRules
InitZoneAmenities(zone = this)
}
}
),
(
"z8",
new Zone("z8", Await.result(Maps.map08, 30 seconds), 8) {
override def Init(implicit context: ActorContext): Unit = {
super.Init(context)
HotSpotCoordinateFunction = Zones.HotSpots.StandardRemapping(Map.Scale, 80, 80)
HotSpotTimeFunction = Zones.HotSpots.StandardTimeRules
InitZoneAmenities(zone = this)
}
}
),
(
"z9",
new Zone("z9", Await.result(Maps.map09, 30 seconds), 9) {
override def Init(implicit context: ActorContext): Unit = {
super.Init(context)
HotSpotCoordinateFunction = Zones.HotSpots.StandardRemapping(Map.Scale, 80, 80)
HotSpotTimeFunction = Zones.HotSpots.StandardTimeRules
InitZoneAmenities(zone = this)
}
}
),
(
"z10",
new Zone("z10", Await.result(Maps.map10, 30 seconds), 10) {
override def Init(implicit context: ActorContext): Unit = {
super.Init(context)
HotSpotCoordinateFunction = Zones.HotSpots.StandardRemapping(Map.Scale, 80, 80)
HotSpotTimeFunction = Zones.HotSpots.StandardTimeRules
InitZoneAmenities(zone = this)
}
}
),
(
"home1",
new Zone("home1", Await.result(Maps.map11, 30 seconds), 11) {
override def Init(implicit context: ActorContext): Unit = {
super.Init(context)
import net.psforever.types.PlanetSideEmpire
Buildings.values.foreach { _.Faction = PlanetSideEmpire.NC }
InitZoneAmenities(zone = this)
}
}
),
(
"home2",
new Zone("home2", Await.result(Maps.map12, 30 seconds), 12) {
override def Init(implicit context: ActorContext): Unit = {
super.Init(context)
import net.psforever.types.PlanetSideEmpire
Buildings.values.foreach { _.Faction = PlanetSideEmpire.TR }
InitZoneAmenities(zone = this)
}
}
),
(
"home3",
new Zone("home3", Await.result(Maps.map13, 30 seconds), 13) {
override def Init(implicit context: ActorContext): Unit = {
super.Init(context)
import net.psforever.types.PlanetSideEmpire
Buildings.values.foreach { _.Faction = PlanetSideEmpire.VS }
InitZoneAmenities(zone = this)
}
}
),
(
"tzshtr",
new Zone("tzshtr", Maps.map14, 14)
),
(
"tzdrtr",
new Zone("tzsdrtr", Maps.map15, 15)
),
(
"tzcotr",
new Zone("tzcotr", Maps.map16, 16)
),
(
"tzshnc",
new Zone("tzshnc", Maps.map14, 17)
),
(
"tzdrnc",
new Zone("tzdrnc", Maps.map15, 18)
),
(
"tzconc",
new Zone("tzconc", Maps.map16, 19)
),
(
"tzshvs",
new Zone("tzshvs", Maps.map14, 20)
),
(
"tzdrvs",
new Zone("tzdrvs", Maps.map15, 21)
),
(
"tzcovs",
new Zone("tzcovs", Maps.map16, 22)
),
(
"c1",
new Zone("c1", Await.result(Maps.ugd01, 30 seconds), 23) {
override def Init(implicit context: ActorContext): Unit = {
super.Init(context)
HotSpotCoordinateFunction = Zones.HotSpots.StandardRemapping(Map.Scale, 80, 80)
HotSpotTimeFunction = Zones.HotSpots.StandardTimeRules
InitZoneAmenities(zone = this)
}
}
),
(
"c2",
new Zone("c2", Await.result(Maps.ugd02, 30 seconds), 24) {
override def Init(implicit context: ActorContext): Unit = {
super.Init(context)
HotSpotCoordinateFunction = Zones.HotSpots.StandardRemapping(Map.Scale, 80, 80)
HotSpotTimeFunction = Zones.HotSpots.StandardTimeRules
InitZoneAmenities(zone = this)
}
}
),
(
"c3",
new Zone("c3", Await.result(Maps.ugd03, 30 seconds), 25) {
override def Init(implicit context: ActorContext): Unit = {
super.Init(context)
HotSpotCoordinateFunction = Zones.HotSpots.StandardRemapping(Map.Scale, 80, 80)
HotSpotTimeFunction = Zones.HotSpots.StandardTimeRules
InitZoneAmenities(zone = this)
}
}
),
(
"c4",
new Zone("c4", Await.result(Maps.ugd04, 30 seconds), 26) {
override def Init(implicit context: ActorContext): Unit = {
super.Init(context)
HotSpotCoordinateFunction = Zones.HotSpots.StandardRemapping(Map.Scale, 80, 80)
HotSpotTimeFunction = Zones.HotSpots.StandardTimeRules
InitZoneAmenities(zone = this)
}
}
),
(
"c5",
new Zone("c5", Await.result(Maps.ugd05, 30 seconds), 27) {
override def Init(implicit context: ActorContext): Unit = {
super.Init(context)
HotSpotCoordinateFunction = Zones.HotSpots.StandardRemapping(Map.Scale, 80, 80)
HotSpotTimeFunction = Zones.HotSpots.StandardTimeRules
InitZoneAmenities(zone = this)
}
}
),
(
"c6",
new Zone("c6", Await.result(Maps.ugd06, 30 seconds), 28) {
override def Init(implicit context: ActorContext): Unit = {
super.Init(context)
HotSpotCoordinateFunction = Zones.HotSpots.StandardRemapping(Map.Scale, 80, 80)
HotSpotTimeFunction = Zones.HotSpots.StandardTimeRules
InitZoneAmenities(zone = this)
}
}
),
(
"i1",
new Zone("i1", Await.result(Maps.map99, 30 seconds), 29) {
override def Init(implicit context: ActorContext): Unit = {
super.Init(context)
HotSpotCoordinateFunction = Zones.HotSpots.StandardRemapping(Map.Scale, 80, 80)
HotSpotTimeFunction = Zones.HotSpots.StandardTimeRules
InitZoneAmenities(zone = this)
}
}
),
(
"i2",
new Zone("i2", Await.result(Maps.map98, 30 seconds), 30) {
override def Init(implicit context: ActorContext): Unit = {
super.Init(context)
HotSpotCoordinateFunction = Zones.HotSpots.StandardRemapping(Map.Scale, 80, 80)
HotSpotTimeFunction = Zones.HotSpots.StandardTimeRules
InitZoneAmenities(zone = this)
}
}
),
(
"i3",
new Zone("i3", Await.result(Maps.map97, 30 seconds), 31) {
override def Init(implicit context: ActorContext): Unit = {
super.Init(context)
HotSpotCoordinateFunction = Zones.HotSpots.StandardRemapping(Map.Scale, 80, 80)
HotSpotTimeFunction = Zones.HotSpots.StandardTimeRules
InitZoneAmenities(zone = this)
}
}
),
(
"i4",
new Zone("i4", Await.result(Maps.map96, 30 seconds), 32) {
override def Init(implicit context: ActorContext): Unit = {
super.Init(context)
HotSpotCoordinateFunction = Zones.HotSpots.StandardRemapping(Map.Scale, 80, 80)
HotSpotTimeFunction = Zones.HotSpots.StandardTimeRules
InitZoneAmenities(zone = this)
}
}
)
)
def InitZoneAmenities(zone: Zone): Unit = {
InitResourceSilos(zone)
InitWarpGates(zone)
def InitWarpGates(zone: Zone): Unit = {
// todo: work out which faction owns links to this warpgate and if they should be marked as broadcast or not
// todo: enable geowarps to go to the correct cave
zone.Buildings.values.collect {
case wg: WarpGate
if wg.Definition == GlobalDefinitions.warpgate || wg.Definition == GlobalDefinitions.warpgate_small =>
wg.Active = true
wg.Faction = PlanetSideEmpire.NEUTRAL
wg.Broadcast = true
case geowarp: WarpGate
if geowarp.Definition == GlobalDefinitions.warpgate_cavern || geowarp.Definition == GlobalDefinitions.hst =>
geowarp.Faction = PlanetSideEmpire.NEUTRAL
geowarp.Active = false
}
}
def InitResourceSilos(zone: Zone): Unit = {
// todo: load silo charge from database
zone.Buildings.values.flatMap {
_.Amenities.collect {
case silo : ResourceSilo =>
silo.Actor ! ResourceSilo.UpdateChargeLevel(silo.MaxNtuCapacitor)
}
}
}
}
/**
* Get the zone identifier name for the sanctuary continent of a given empire.
* @param faction the empire
* @return the zone id, with a blank string as an invalidating result
*/
def SanctuaryZoneId(faction: PlanetSideEmpire.Value): String = {
faction match {
case PlanetSideEmpire.NC => "home1"
case PlanetSideEmpire.TR => "home2"
case PlanetSideEmpire.VS => "home3"
case PlanetSideEmpire.NEUTRAL => "" //invalid, not black ops
}
}
/**
* Get the zone number for the sanctuary continent of a given empire.
* @param faction the empire
* @return the zone number, within the sequence 1-32, and with 0 as an invalidating result
*/
def SanctuaryZoneNumber(faction: PlanetSideEmpire.Value): Int = {
faction match {
case PlanetSideEmpire.NC => 11
case PlanetSideEmpire.TR => 12
case PlanetSideEmpire.VS => 13
case PlanetSideEmpire.NEUTRAL => 0 //invalid, not black ops
}
}
/**
* Given a zone identification string, provide that zone's ordinal number.
* As zone identification naming is extremely formulaic,
* just being able to poll the zone's identifier by its first few letters will produce its ordinal position.
* @param id a zone id string
* @return a zone number
*/
def NumberFromId(id: String): Int = {
if (id.startsWith("z")) { //z2 -> 2
id.substring(1).toInt
} else if (id.startsWith("home")) { //home2 -> 2 + 10 = 12
id.substring(4).toInt + 10
} else if (id.startsWith("tz")) { //tzconc -> (14 + (3 * 1) + 2) -> 19
(List("tr", "nc", "vs").indexOf(id.substring(4)) * 3) + List("sh", "dr", "co").indexOf(id.substring(2, 4)) + 14
} else if (id.startsWith("c")) { //c2 -> 2 + 21 = 23
id.substring(1).toInt + 21
} else if (id.startsWith("i")) { //i2 -> 2 + 28 = 30
id.substring(1).toInt + 28
} else {
0
}
}
object HotSpots {
import net.psforever.objects.ballistics.SourceEntry
import net.psforever.objects.zones.MapScale
import net.psforever.types.Vector3
import scala.concurrent.duration._
/**
* Produce hotspot coordinates based on map coordinates.
* @see `FindClosestDivision`
* @param scale the map's scale (width and height)
* @param longDivNum the number of division lines spanning the width of the `scale`
* @param latDivNum the number of division lines spanning the height of the `scale`
* @param pos the absolute position of the activity reported
* @return the position for a hotspot
*/
def StandardRemapping(scale: MapScale, longDivNum: Int, latDivNum: Int)(pos: Vector3): Vector3 = {
Vector3(
//x
FindClosestDivision(pos.x, scale.width, longDivNum.toFloat),
//y
FindClosestDivision(pos.y, scale.height, latDivNum.toFloat),
//z is always zero - maps are flat 2D planes
0
)
}
/**
* Produce hotspot coordinates based on map coordinates.<br>
* <br>
* Transform a reported number by mapping it
* into a division from a regular pattern of divisions
* defined by the scale divided evenly a certain number of times.
* The depicted number of divisions is actually one less than the parameter number
* as the first division is used to represent everything before that first division (there is no "zero").
* Likewise, the last division occurs before the farther edge of the scale is counted
* and is used to represent everything after that last division.
* This is not unlike rounding.
* @param coordinate the point to scale
* @param scale the map's scale (width and height)
* @param divisions the number of division lines spanning across the `scale`
* @return the closest regular division
*/
private def FindClosestDivision(coordinate: Float, scale: Float, divisions: Float): Float = {
val divLength: Float = scale / divisions
if (coordinate >= scale - divLength) {
scale - divLength
} else if (coordinate >= divLength) {
val sector: Float = (coordinate * divisions / scale).toInt * divLength
val nextSector: Float = sector + divLength
if (coordinate - sector < nextSector - coordinate) {
sector
} else {
nextSector
}
} else {
divLength
}
}
/**
* Determine a duration for which the hotspot will be displayed on the zone map.
* Friendly fire is not recognized.
* @param defender the defending party
* @param attacker the attacking party
* @return the duration
*/
def StandardTimeRules(defender: SourceEntry, attacker: SourceEntry): FiniteDuration = {
import net.psforever.objects.GlobalDefinitions
import net.psforever.objects.ballistics._
if (attacker.Faction == defender.Faction) {
0 seconds
} else {
//TODO is target occupy-able and occupied, or jammer-able and jammered?
defender match {
case _: PlayerSource =>
60 seconds
case _: VehicleSource =>
60 seconds
case t: ObjectSource if t.Definition == GlobalDefinitions.manned_turret =>
60 seconds
case _: DeployableSource =>
30 seconds
case _: ComplexDeployableSource =>
30 seconds
case _ =>
0 seconds
}
}
}
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -15,14 +15,14 @@ object Service {
}
trait GenericEventBusMsg {
def toChannel: String
def channel: String
}
class GenericEventBus[A <: GenericEventBusMsg] extends ActorEventBus with SubchannelClassification {
type Event = A
type Classifier = String
protected def classify(event: Event): Classifier = event.toChannel
protected def classify(event: Event): Classifier = event.channel
protected def subclassification =
new Subclassification[Classifier] {

View file

@ -12,7 +12,7 @@ import net.psforever.types.{ExoSuitType, PlanetSideEmpire, PlanetSideGUID, Trans
import services.GenericEventBusMsg
final case class AvatarServiceResponse(
toChannel: String,
channel: String,
avatar_guid: PlanetSideGUID,
replyMessage: AvatarResponse.Response
) extends GenericEventBusMsg

View file

@ -1,62 +0,0 @@
// Copyright (c) 2017 PSForever
package services.chat
import net.psforever.objects.zones.Zone
import net.psforever.packet.game.ChatMsg
import net.psforever.types.{PlanetSideEmpire, PlanetSideGUID, Vector3}
object ChatAction {
sealed trait Action
final case class Local(
player_guid: PlanetSideGUID,
player_name: String,
continent: Zone,
player_pos: Vector3,
player_faction: PlanetSideEmpire.Value,
msg: ChatMsg
) extends Action
final case class Tell(player_guid: PlanetSideGUID, player_name: String, msg: ChatMsg) extends Action
final case class Broadcast(
player_guid: PlanetSideGUID,
player_name: String,
continent: Zone,
player_pos: Vector3,
player_faction: PlanetSideEmpire.Value,
msg: ChatMsg
) extends Action
final case class Voice(
player_guid: PlanetSideGUID,
player_name: String,
continent: Zone,
player_pos: Vector3,
player_faction: PlanetSideEmpire.Value,
msg: ChatMsg
) extends Action
final case class Note(player_guid: PlanetSideGUID, player_name: String, msg: ChatMsg) extends Action
final case class Squad(
player_guid: PlanetSideGUID,
player_name: String,
continent: Zone,
player_pos: Vector3,
player_faction: PlanetSideEmpire.Value,
msg: ChatMsg
) extends Action
final case class Platoon(
player_guid: PlanetSideGUID,
player_name: String,
continent: Zone,
player_pos: Vector3,
player_faction: PlanetSideEmpire.Value,
msg: ChatMsg
) extends Action
final case class Command(
player_guid: PlanetSideGUID,
player_name: String,
continent: Zone,
player_pos: Vector3,
player_faction: PlanetSideEmpire.Value,
msg: ChatMsg
) extends Action
final case class GM(player_guid: PlanetSideGUID, player_name: String, msg: ChatMsg) extends Action
}

View file

@ -1,90 +0,0 @@
// Copyright (c) 2017 PSForever
package services.chat
import net.psforever.types.{ChatMessageType, PlanetSideGUID}
object ChatResponse {
sealed trait Response
final case class Local(
sender: String,
messageType: ChatMessageType.Value,
wideContents: Boolean,
recipient: String,
contents: String,
note: Option[String]
) extends Response
final case class Tell(
sender: String,
messageType: ChatMessageType.Value,
wideContents: Boolean,
recipient: String,
contents: String,
note: Option[String]
) extends Response
final case class UTell(
sender: String,
messageType: ChatMessageType.Value,
wideContents: Boolean,
recipient: String,
contents: String,
note: Option[String]
) extends Response
final case class Broadcast(
messageType: ChatMessageType.Value,
wideContents: Boolean,
recipient: String,
contents: String,
note: Option[String]
) extends Response
final case class Voice(
messageType: ChatMessageType.Value,
wideContents: Boolean,
recipient: String,
contents: String,
note: Option[String]
) extends Response
final case class Unk45(
sender: String,
messageType: ChatMessageType.Value,
wideContents: Boolean,
recipient: String,
contents: String,
note: Option[String]
) extends Response
final case class Squad(
sender: String,
messageType: ChatMessageType.Value,
wideContents: Boolean,
recipient: String,
contents: String,
note: Option[String]
) extends Response
final case class Platoon(
sender: String,
messageType: ChatMessageType.Value,
wideContents: Boolean,
recipient: String,
contents: String,
note: Option[String]
) extends Response
final case class Command(
sender: String,
messageType: ChatMessageType.Value,
wideContents: Boolean,
recipient: String,
contents: String,
note: Option[String]
) extends Response
final case class Text(
toChannel: String,
avatar_guid: PlanetSideGUID,
personal: Int,
messageType: ChatMessageType.Value,
wideContents: Boolean,
recipient: String,
contents: String,
note: Option[String]
)
}

View file

@ -1,227 +1,188 @@
// Copyright (c) 2017 PSForever
package services.chat
import akka.actor.Actor
import net.psforever.objects.LivePlayerList
import akka.actor.typed.receptionist.{Receptionist, ServiceKey}
import akka.actor.typed.{ActorRef, Behavior}
import akka.actor.typed.scaladsl.{AbstractBehavior, ActorContext, Behaviors}
import net.psforever.objects.Session
import net.psforever.packet.game.ChatMsg
import net.psforever.types.ChatMessageType
import services.{GenericEventBus, Service}
import net.psforever.types.{ChatMessageType, PlanetSideEmpire, PlanetSideGUID}
class ChatService extends Actor {
private[this] val log = org.log4s.getLogger
object ChatService {
val ChatServiceKey: ServiceKey[Command] = ServiceKey[ChatService.Command]("chatService")
override def preStart = {
log.info("Starting....")
def apply(): Behavior[Command] =
Behaviors.setup { context =>
context.system.receptionist ! Receptionist.Register(ChatServiceKey, context.self)
new ChatService(context)
}
sealed trait Command
final case class JoinChannel(actor: ActorRef[MessageResponse], session: Session, channel: ChatChannel) extends Command
final case class LeaveChannel(actor: ActorRef[MessageResponse], channel: ChatChannel) extends Command
final case class LeaveAllChannels(actor: ActorRef[MessageResponse]) extends Command
final case class Message(session: Session, message: ChatMsg, channel: ChatChannel) extends Command
final case class MessageResponse(session: Session, message: ChatMsg, channel: ChatChannel)
trait ChatChannel
object ChatChannel {
// one of the default channels that the player is always subscribed to (local, broadcast, command...)
final case class Default() extends ChatChannel
final case class Squad(guid: PlanetSideGUID) extends ChatChannel
}
val ChatEvents = new GenericEventBus[ChatServiceResponse]
}
def receive = {
case Service.Join(channel) =>
val path = s"/Chat/$channel"
val who = sender()
log.info(s"$who has joined $path")
ChatEvents.subscribe(who, path)
case Service.Leave(None) =>
ChatEvents.unsubscribe(sender())
case Service.Leave(Some(channel)) =>
val path = s"/Chat/$channel"
val who = sender()
log.info(s"$who has left $path")
ChatEvents.unsubscribe(who, path)
case Service.LeaveAll() =>
ChatEvents.unsubscribe(sender())
class ChatService(context: ActorContext[ChatService.Command]) extends AbstractBehavior[ChatService.Command](context) {
case ChatServiceMessage(forChannel, action) =>
action match {
case ChatAction.Local(player_guid, player_name, cont, player_pos, player_faction, msg) => // local
ChatEvents.publish(
ChatServiceResponse(
s"/Chat/$forChannel",
player_guid,
player_name,
cont,
player_pos,
player_faction,
2,
ChatMsg(ChatMessageType.CMT_OPEN, msg.wideContents, player_name, msg.contents, None)
)
)
case ChatAction.Tell(player_guid, player_name, msg) => // tell
var good: Boolean = false
LivePlayerList
.WorldPopulation(_ => true)
.foreach(char => {
if (char.name.equalsIgnoreCase(msg.recipient)) {
good = true
}
})
if (good) {
ChatEvents.publish(
ChatServiceResponse(
s"/Chat/$forChannel",
player_guid,
player_name,
target = 0,
replyMessage = ChatMsg(ChatMessageType.CMT_TELL, msg.wideContents, msg.recipient, msg.contents, None)
)
)
ChatEvents.publish(
ChatServiceResponse(
s"/Chat/$forChannel",
player_guid,
player_name,
target = 1,
replyMessage =
ChatMsg(ChatMessageType.U_CMT_TELLFROM, msg.wideContents, msg.recipient, msg.contents, None)
)
)
} else {
ChatEvents.publish(
ChatServiceResponse(
s"/Chat/$forChannel",
player_guid,
player_name,
target = 1,
replyMessage =
ChatMsg(ChatMessageType.U_CMT_TELLFROM, msg.wideContents, msg.recipient, msg.contents, None)
)
)
ChatEvents.publish(
ChatServiceResponse(
s"/Chat/$forChannel",
player_guid,
player_name,
target = 1,
replyMessage = ChatMsg(ChatMessageType.UNK_45, msg.wideContents, "", "@NoTell_Target", None)
)
)
}
case ChatAction.Broadcast(player_guid, player_name, cont, player_pos, player_faction, msg) => // broadcast
ChatEvents.publish(
ChatServiceResponse(
s"/Chat/$forChannel",
player_guid,
player_name,
cont,
player_pos,
player_faction,
2,
ChatMsg(msg.messageType, msg.wideContents, player_name, msg.contents, None)
)
)
case ChatAction.Voice(player_guid, player_name, cont, player_pos, player_faction, msg) => // voice
ChatEvents.publish(
ChatServiceResponse(
s"/Chat/$forChannel",
player_guid,
player_name,
cont,
player_pos,
player_faction,
2,
ChatMsg(ChatMessageType.CMT_VOICE, false, player_name, msg.contents, None)
)
)
import ChatService._
import ChatMessageType._
case ChatAction.Note(player_guid, player_name, msg) => // note
ChatEvents.publish(
ChatServiceResponse(
s"/Chat/$forChannel",
player_guid,
player_name,
target = 1,
replyMessage = ChatMsg(ChatMessageType.U_CMT_GMTELLFROM, true, msg.recipient, msg.contents, None)
)
)
ChatEvents.publish(
ChatServiceResponse(
s"/Chat/$forChannel",
player_guid,
player_name,
target = 1,
replyMessage = ChatMsg(
ChatMessageType.CMT_GMTELL,
true,
"Server",
"Why do you try to /note ? That's a GM command ! ... Or not, nobody can /note",
None
)
)
)
case ChatAction.Squad(player_guid, player_name, cont, player_pos, player_faction, msg) => // squad
ChatEvents.publish(
ChatServiceResponse(
s"/Chat/$forChannel",
player_guid,
player_name,
cont,
player_pos,
player_faction,
2,
ChatMsg(ChatMessageType.CMT_SQUAD, msg.wideContents, player_name, msg.contents, None)
)
)
case ChatAction.Platoon(player_guid, player_name, cont, player_pos, player_faction, msg) => // platoon
ChatEvents.publish(
ChatServiceResponse(
s"/Chat/$forChannel",
player_guid,
player_name,
cont,
player_pos,
player_faction,
2,
ChatMsg(ChatMessageType.CMT_PLATOON, msg.wideContents, player_name, msg.contents, None)
)
)
case ChatAction.Command(player_guid, player_name, cont, player_pos, player_faction, msg) => // command
ChatEvents.publish(
ChatServiceResponse(
s"/Chat/$forChannel",
player_guid,
player_name,
cont,
player_pos,
player_faction,
2,
ChatMsg(ChatMessageType.CMT_COMMAND, msg.wideContents, player_name, msg.contents, None)
)
)
case ChatAction.GM(player_guid, player_name, msg) => // GM
msg.messageType match {
case ChatMessageType.CMT_SILENCE =>
ChatEvents.publish(
ChatServiceResponse(
s"/Chat/$forChannel",
player_guid,
msg.contents,
target = 0,
replyMessage = ChatMsg(ChatMessageType.CMT_SILENCE, true, "", "", None)
private[this] val log = org.log4s.getLogger
var subscriptions: List[JoinChannel] = List[JoinChannel]()
override def onMessage(msg: Command): Behavior[Command] = {
msg match {
case subscription: JoinChannel =>
subscriptions ++= List(subscription)
this
case LeaveChannel(actor, channel) =>
subscriptions = subscriptions.filter {
case JoinChannel(a, _, c) => actor != a && channel != c
}
this
case LeaveAllChannels(actor) =>
subscriptions = subscriptions.filter {
case JoinChannel(a, _, _) => actor != a
}
this
case Message(session, message, channel) =>
(channel, message.messageType) match {
case (ChatChannel.Squad(_), CMT_SQUAD) =>
case (ChatChannel.Default(), messageType) if messageType != CMT_SQUAD =>
case _ =>
log.error(s"invalid chat channel $channel for messageType ${message.messageType}")
return this
}
val subs = subscriptions.filter(_.channel == channel)
message.messageType match {
case CMT_TELL | CMT_GMTELL =>
subs.find(_.session.player.Name == session.player.Name).foreach {
case JoinChannel(sender, _, _) =>
sender ! MessageResponse(
session,
message.copy(messageType = if (message.messageType == CMT_TELL) U_CMT_TELLFROM else U_CMT_GMTELLFROM),
channel
)
)
// if(player_guid != PlanetSideGUID(0)) {
//
// val args = msg.contents.split(" ")
// var silence_name : String = ""
// var silence_time : Int = 5
// if (args.length == 1) {
// silence_name = args(0)
// }
// else if (args.length == 2) {
// silence_name = args(0)
// silence_time = args(1).toInt
// }
// ChatEvents.publish(
// ChatServiceResponse(s"/Chat/$forChannel", player_guid, player_name, target = 1, replyMessage = ChatMsg(ChatMessageType.UNK_45, true, "", silence_name + " silenced for " + silence_time + " min(s)", None))
// )
// }
case _ => ;
}
case _ => ;
}
subs.find(_.session.player.Name == message.recipient) match {
case Some(JoinChannel(receiver, _, _)) =>
receiver ! MessageResponse(session, message, channel)
case None =>
sender ! MessageResponse(
session,
ChatMsg(ChatMessageType.UNK_45, false, "", "@NoTell_Target", None),
channel
)
}
case msg =>
log.info(s"Unhandled message $msg from $sender")
}
case CMT_SILENCE =>
val args = message.contents.split(" ")
val (name, time, error) = (args.lift(0), args.lift(1)) match {
case (Some(name), None) => (Some(name), Some(5), None)
case (Some(name), Some(time)) =>
time.toIntOption match {
case Some(time) =>
(Some(name), Some(time), None)
case None =>
(None, None, Some("bad time format"))
}
case _ => (None, None, None)
}
val sender = subs.find(_.session.player.Name == session.player.Name)
(sender, name, time, error) match {
case (Some(sender), Some(name), Some(_), None) =>
val recipient = subs.find(_.session.player.Name == name)
recipient match {
case Some(recipient) =>
if (recipient.session.player.silenced) {
sender.actor ! MessageResponse(
session,
ChatMsg(UNK_71, true, "", "@silence_disabled_ack", None),
channel
)
} else {
sender.actor ! MessageResponse(
session,
ChatMsg(UNK_71, true, "", "@silence_enabled_ack", None),
channel
)
}
recipient.actor ! MessageResponse(session, message, channel)
case None =>
sender.actor ! MessageResponse(
session,
ChatMsg(UNK_71, true, "", s"unknown player '$name'", None),
channel
)
}
case (Some(sender), _, _, error) =>
sender.actor ! MessageResponse(
session,
ChatMsg(UNK_71, false, "", error.getOrElse("usage: /silence <name> [<time>]"), None),
channel
)
case (None, _, _, _) =>
log.error("received message from non-subscribed actor")
}
case CMT_NOTE =>
subs.filter(_.session.player.Name == message.recipient).foreach {
case JoinChannel(actor, _, _) =>
actor ! MessageResponse(session, message.copy(recipient = session.player.Name), channel)
}
// faction commands
case CMT_OPEN | CMT_PLATOON | CMT_COMMAND =>
subs.filter(_.session.player.Faction == session.player.Faction).foreach {
case JoinChannel(actor, _, _) => actor ! MessageResponse(session, message, channel)
}
case CMT_GMBROADCAST_NC =>
subs.filter(_.session.player.Faction == PlanetSideEmpire.NC).foreach {
case JoinChannel(actor, _, _) => actor ! MessageResponse(session, message, channel)
}
case CMT_GMBROADCAST_TR =>
subs.filter(_.session.player.Faction == PlanetSideEmpire.TR).foreach {
case JoinChannel(actor, _, _) => actor ! MessageResponse(session, message, channel)
}
case CMT_GMBROADCAST_VS =>
subs.filter(_.session.player.Faction == PlanetSideEmpire.VS).foreach {
case JoinChannel(actor, _, _) => actor ! MessageResponse(session, message, channel)
}
// cross faction commands
case CMT_BROADCAST | CMT_VOICE | CMT_GMBROADCAST | CMT_GMBROADCASTPOPUP | UNK_227 =>
subs.foreach {
case JoinChannel(actor, _, _) => actor ! MessageResponse(session, message, channel)
}
case _ =>
log.warn(s"unhandled chat message, add a case for $message")
}
this
}
}
}

View file

@ -1,4 +0,0 @@
// Copyright (c) 2017 PSForever
package services.chat
final case class ChatServiceMessage(forChannel: String, actionMessage: ChatAction.Action)

View file

@ -1,18 +0,0 @@
// Copyright (c) 2017 PSForever
package services.chat
import net.psforever.objects.zones.Zone
import net.psforever.packet.game.ChatMsg
import net.psforever.types.{PlanetSideEmpire, PlanetSideGUID, Vector3}
import services.GenericEventBusMsg
final case class ChatServiceResponse(
toChannel: String,
avatar_guid: PlanetSideGUID,
avatar_name: String,
cont: Zone = Zone.Nowhere,
avatar_pos: Vector3 = Vector3(0f, 0f, 0f),
avatar_faction: PlanetSideEmpire.Value = PlanetSideEmpire.NEUTRAL,
target: Int,
replyMessage: ChatMsg
) extends GenericEventBusMsg

View file

@ -8,7 +8,7 @@ import net.psforever.packet.game.BuildingInfoUpdateMessage
import net.psforever.types.PlanetSideGUID
import services.GenericEventBusMsg
final case class GalaxyServiceResponse(toChannel: String, replyMessage: GalaxyResponse.Response)
final case class GalaxyServiceResponse(channel: String, replyMessage: GalaxyResponse.Response)
extends GenericEventBusMsg
object GalaxyResponse {

View file

@ -10,7 +10,7 @@ import net.psforever.types.{PlanetSideEmpire, PlanetSideGUID, Vector3}
import services.GenericEventBusMsg
final case class LocalServiceResponse(
toChannel: String,
channel: String,
avatar_guid: PlanetSideGUID,
replyMessage: LocalResponse.Response
) extends GenericEventBusMsg

View file

@ -2,15 +2,22 @@
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.objects.{Avatar, LivePlayerList, Player}
import net.psforever.packet.game.{
SquadDetail,
SquadInfo,
WaypointEventAction,
SquadPositionEntry,
SquadPositionDetail,
WaypointInfo,
PlanetSideZoneID
}
import net.psforever.types._
import services.{GenericEventBus, Service}
import services.teamwork.SquadAction
import scala.collection.concurrent.TrieMap
import scala.collection.mutable

View file

@ -2,12 +2,11 @@
package services.teamwork
import net.psforever.objects.teamwork.Squad
import net.psforever.packet.game._
import net.psforever.packet.game.{SquadDetail, SquadInfo, WaypointEventAction, WaypointInfo}
import net.psforever.types.{PlanetSideGUID, SquadResponseType, SquadWaypoints}
import services.GenericEventBusMsg
import services.teamwork.SquadAction
final case class SquadServiceResponse(toChannel: String, exclude: Iterable[Long], response: SquadResponse.Response)
final case class SquadServiceResponse(channel: String, exclude: Iterable[Long], response: SquadResponse.Response)
extends GenericEventBusMsg
object SquadServiceResponse {

View file

@ -14,7 +14,7 @@ import net.psforever.types.{BailType, DriveState, PlanetSideGUID, Vector3}
import services.GenericEventBusMsg
final case class VehicleServiceResponse(
toChannel: String,
channel: String,
avatar_guid: PlanetSideGUID,
replyMessage: VehicleResponse.Response
) extends GenericEventBusMsg