diff --git a/.gitignore b/.gitignore
index 3b4218c6..60d79aab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,9 @@ out/
*.ipr
*.iml
+# User configs
+config/worldserver.ini
+
# Log files
*.log
logs/
diff --git a/README.md b/README.md
index 8b04af9b..f46f918f 100644
--- a/README.md
+++ b/README.md
@@ -58,6 +58,20 @@ Using SBT, you can generate documentation for both the common and pslogin projec
Current documentation is available at [https://psforever.github.io/docs/master/index.html](https://psforever.github.io/docs/master/index.html)
+## Setting up the Database
+The Login and World servers require PostgreSQL for persistence.
+
+* Windows - [Official Downloads](https://www.postgresql.org/download/windows/)
+* Linux - [Debian](https://www.postgresql.org/download/linux/debian/) or [Ubuntu](https://www.postgresql.org/download/linux/ubuntu/)
+* macOS - Application https://www.postgresql.org/download/ (or `brew install postgresql && brew services start postgresql`)
+
+The default database is named `psforever` and the credentials are `psforever:psforever`. To change these, make a copy of `[config/worldserver.ini.dist](config/worldserver.ini.dist)` to `config/worldserver.ini` and change the corresponding fields in the database section.
+
+Once you have installed the database to your local system or have access to a remote database, you need to synchronize the schema. This is currently available in `[schema.sql](schema.sql)`.
+Loading this in requires access to a graphical tool such as [pgAdmin](https://www.pgadmin.org/download/) (highly recommended) or a PostgreSQL terminal (`psql`) for advanced users.
+To get started using pgAdmin, run the binary. This will start the pgAdmin server and pop-up a tab in your web browser with the interface. Upon first run, enter your connection details that you created during the PostgreSQL installation. When connected, right click the "Databases" menu -> Create... -> Database: psforever -> Save. Next, right click on the newly created database (psforever) -> Query Tool... -> Copy and paste / Open the `schema.sql` file into the editor -> Hit the "Play/Run" button. The schema should be loaded into the database.
+Once you have the schema loaded in, the LoginServer will automatically create accounts on first login. If you'd like a nice account management interface, check out the [PSFPortal](https://github.com/psforever/PSFPortal) web interface.
+
## Running the Server
To run a headless, non-interactive server, run
diff --git a/THANKS.md b/THANKS.md
index de26b681..86f85372 100644
--- a/THANKS.md
+++ b/THANKS.md
@@ -1,13 +1,14 @@
PlanetSide Forever (PSForever) would not have been possible without the passion and many hours of hard work of our contributors.
If you feel you are missing from this list, please open a Pull Request!
-Code Contributors (most active first, then anyone who has commits)
+Code Contributors
===================
* FateJH & Chord
* SouNourS
* tfarley
* aphedox
* L-11
+* xTriad (initial database schema)
Packet Capturing 2015-2016 (in order of most packets captured)
=================
diff --git a/build.sbt b/build.sbt
index 5d41f9cf..431ff8ce 100644
--- a/build.sbt
+++ b/build.sbt
@@ -46,7 +46,8 @@ lazy val commonSettings = Seq(
"org.fusesource.jansi" % "jansi" % "1.12",
"org.scoverage" %% "scalac-scoverage-plugin" % "1.1.1",
"com.github.nscala-time" %% "nscala-time" % "2.12.0",
- "com.github.mauricio" %% "mysql-async" % "0.2.21",
+ "com.github.mauricio" %% "postgresql-async" % "0.2.21",
+ "com.github.t3hnar" %% "scala-bcrypt" % "3.1",
"org.ini4j" % "ini4j" % "0.5.4",
"org.scala-graph" %% "graph-core" % "1.12.5"
)
diff --git a/common/src/main/scala/net/psforever/config/ConfigParser.scala b/common/src/main/scala/net/psforever/config/ConfigParser.scala
index 24942d40..efcadb3a 100644
--- a/common/src/main/scala/net/psforever/config/ConfigParser.scala
+++ b/common/src/main/scala/net/psforever/config/ConfigParser.scala
@@ -3,14 +3,18 @@ package net.psforever.config
import org.ini4j
import scala.reflect.ClassTag
+import scala.reflect.runtime.universe.TypeTag
import scala.annotation.implicitNotFound
import scala.concurrent.duration._
+import scala.collection.mutable.Map
case class ConfigValueMapper[T](name: String)(f: (String => Option[T])) {
def apply(t: String): Option[T] = f(t)
}
object ConfigValueMapper {
+ import scala.language.implicitConversions
+
implicit val toInt : ConfigValueMapper[Int] = ConfigValueMapper[Int]("toInt") { e =>
try {
Some(e.toInt)
@@ -19,6 +23,17 @@ object ConfigValueMapper {
}
}
+ // TypeTag is necessary to be able to retrieve an instance of the Enum class
+ // at runtime as it is usually erased at runtime. This is low cost as its only
+ // used during the config parsing
+ implicit def toEnum[E <: Enumeration#Value](implicit m: TypeTag[E]) : ConfigValueMapper[E] = ConfigValueMapper[E]("toEnum") { e =>
+ try {
+ Some(EnumReflector.withName[E](e))
+ } catch {
+ case e: Exception => None
+ }
+ }
+
implicit val toBool : ConfigValueMapper[Boolean] = ConfigValueMapper[Boolean]("toBool") { e =>
if (e == "yes") {
Some(true)
@@ -77,6 +92,13 @@ final case class ConfigEntryBool(key: String, default : Boolean, constraints : C
def read(v : String) = ConfigValueMapper.toBool(v)
}
+final case class ConfigEntryEnum[E <: Enumeration](key: String, default : E#Value)(implicit m : TypeTag[E#Value], implicit val m2 : TypeTag[E#ValueSet]) extends ConfigEntry {
+ type Value = E#Value
+ val constraints : Seq[Constraint[E#Value]] = Seq()
+ def getType = EnumReflector.values[E#ValueSet](m2).mkString(", ")
+ def read(v : String) = ConfigValueMapper.toEnum[E#Value](m)(v)
+}
+
final case class ConfigEntryFloat(key: String, default : Float, constraints : Constraint[Float]*) extends ConfigEntry {
type Value = Float
def getType = "Float"
@@ -101,7 +123,7 @@ object ConfigTypeRequired {
}
trait ConfigParser {
- protected var config_map : Map[String, Any]
+ protected var config_map : Map[String, Any] = Map()
protected val config_template : Seq[ConfigSection]
// Misuse of this function can lead to run time exceptions when the types don't match
@@ -118,8 +140,25 @@ trait ConfigParser {
}
def Load(filename : String) : ValidationResult = {
+ var map : Map[String, Any] = scala.collection.mutable.Map()
+
+ LoadMap(filename, map) match {
+ case i : Invalid =>
+ i
+ case Valid =>
+ ReplaceConfig(map)
+ // run post-parse validation only if we successfully parsed
+ postParseChecks match {
+ case i : Invalid =>
+ i
+ case Valid =>
+ Valid
+ }
+ }
+ }
+
+ def LoadMap(filename : String, map : Map[String, Any]) : ValidationResult = {
val ini = new org.ini4j.Ini()
- config_map = Map()
try {
ini.load(new java.io.File(filename))
@@ -136,7 +175,7 @@ trait ConfigParser {
if (sectionIni == null)
Seq(Invalid("section.missing", section.name))
else
- section.entries.map(parseSection(sectionIni, _))
+ section.entries.map(parseSection(sectionIni, _, map))
}.reduceLeft((x, y) => x ++ y)
val errors : Seq[Invalid] = result.collect { case iv : Invalid => iv }
@@ -144,8 +183,11 @@ trait ConfigParser {
if (errors.length > 0)
errors.reduceLeft((x, y) => x ++ y)
else
- // run post-parse validation only if we successfully parsed
- postParseChecks
+ Valid
+ }
+
+ def ReplaceConfig(map : Map[String, Any]) {
+ config_map = map
}
def FormatErrors(invalidResult : Invalid) : Seq[String] = {
@@ -166,12 +208,12 @@ trait ConfigParser {
Valid
}
- protected def parseSection(sectionIni : org.ini4j.Profile.Section, entry : ConfigEntry) : ValidationResult = {
+ protected def parseSection(sectionIni : org.ini4j.Profile.Section, entry : ConfigEntry, map : Map[String, Any]) : ValidationResult = {
var rawValue = sectionIni.get(entry.key)
- val full_key = sectionIni.getName + "." + entry.key
+ val full_key : String = sectionIni.getName + "." + entry.key
val value = if (rawValue == null) {
- // warn about defaults from unset parameters?
+ println(s"config warning: missing key '${entry.key}', using default value '${entry.default}'")
entry.default
} else {
rawValue = rawValue.stripPrefix("\"").stripSuffix("\"")
@@ -181,7 +223,8 @@ trait ConfigParser {
case None => return Invalid(ValidationError(String.format("%s: value format error (expected: %s)", full_key, entry.getType)))
}
}
- config_map += (full_key -> value)
+
+ map += (full_key -> value)
ParameterValidator(entry.constraints, Some(value)) match {
case v @ Valid => v
diff --git a/common/src/main/scala/net/psforever/config/ConfigValidation.scala b/common/src/main/scala/net/psforever/config/ConfigValidation.scala
index b50a2039..dcfb969e 100644
--- a/common/src/main/scala/net/psforever/config/ConfigValidation.scala
+++ b/common/src/main/scala/net/psforever/config/ConfigValidation.scala
@@ -196,7 +196,7 @@ trait Constraints {
require(error != null, "error must not be null")
if (o == null) Invalid(ValidationError(error, regex))
- else regex.unapplySeq(o).map(_ => Valid).getOrElse(Invalid(ValidationError(error, regex)))
+ else regex.unapplySeq(o).map(_ => Valid).getOrElse(Invalid(ValidationError(error, name)))
}
}
diff --git a/common/src/main/scala/net/psforever/config/EnumReflector.scala b/common/src/main/scala/net/psforever/config/EnumReflector.scala
new file mode 100644
index 00000000..eb251081
--- /dev/null
+++ b/common/src/main/scala/net/psforever/config/EnumReflector.scala
@@ -0,0 +1,54 @@
+// Copyright (c) 2019 PSForever
+package net.psforever.config
+
+import scala.reflect.runtime.universe._
+
+/**
+ * Scala [[Enumeration]] helpers implementing Scala versions of
+ * Java's [[java.lang.Enum.valueOf(Class[Enum], String)]].
+ * @author Dmitriy Yefremov (http://yefremov.net/blog/scala-enum-by-name/)
+ */
+object EnumReflector {
+
+ private val mirror: Mirror = runtimeMirror(getClass.getClassLoader)
+
+ /**
+ * Returns a value of the specified enumeration with the given name.
+ * @param name value name
+ * @tparam T enumeration type
+ * @return enumeration value, see [[scala.Enumeration.withName(String)]]
+ */
+ def withName[T <: Enumeration#Value: TypeTag](name: String): T = {
+ typeOf[T] match {
+ case valueType @ TypeRef(enumType, _, _) =>
+ val methodSymbol = factoryMethodSymbol(enumType, "withName")
+ val moduleSymbol = enumType.termSymbol.asModule
+ reflect(moduleSymbol, methodSymbol)(name).asInstanceOf[T]
+ }
+ }
+
+ /**
+ * Returns the set of values of an enumeration
+ * @tparam T enumeration type
+ * @return possible enumeration values, see [[scala.Enumeration.values()]]
+ */
+ def values[T <: Enumeration#ValueSet: TypeTag]: T = {
+ typeOf[T] match {
+ case valueType @ TypeRef(enumType, _, _) =>
+ val methodSymbol = factoryMethodSymbol(enumType, "values")
+ val moduleSymbol = enumType.termSymbol.asModule
+ reflect(moduleSymbol, methodSymbol)().asInstanceOf[T]
+ }
+ }
+
+ private def factoryMethodSymbol(enumType: Type, name : String): MethodSymbol = {
+ enumType.member(TermName(name)).asMethod
+ }
+
+ private def reflect(module: ModuleSymbol, method: MethodSymbol)(args: Any*): Any = {
+ val moduleMirror = mirror.reflectModule(module)
+ val instanceMirror = mirror.reflect(moduleMirror.instance)
+ instanceMirror.reflectMethod(method)(args:_*)
+ }
+
+}
diff --git a/common/src/main/scala/net/psforever/objects/Account.scala b/common/src/main/scala/net/psforever/objects/Account.scala
new file mode 100644
index 00000000..1e2de47c
--- /dev/null
+++ b/common/src/main/scala/net/psforever/objects/Account.scala
@@ -0,0 +1,8 @@
+// Copyright (c) 2017 PSForever
+package net.psforever.objects
+
+class Account(private val accountId : Int, private val username : String, private val gm : Boolean = false) {
+ def AccountId : Int = accountId
+ def Username : String = username
+ def GM : Boolean = gm
+}
diff --git a/common/src/main/scala/net/psforever/packet/game/VNLWorldStatusMessage.scala b/common/src/main/scala/net/psforever/packet/game/VNLWorldStatusMessage.scala
index c6c2371e..b0829c31 100644
--- a/common/src/main/scala/net/psforever/packet/game/VNLWorldStatusMessage.scala
+++ b/common/src/main/scala/net/psforever/packet/game/VNLWorldStatusMessage.scala
@@ -24,6 +24,7 @@ object ServerType extends Enumeration(1) {
implicit val codec = PacketHelpers.createEnumerationCodec(this, uint8L)
}
+// This MUST be an IP address. The client DOES NOT do name resolution properly
final case class WorldConnectionInfo(address : InetSocketAddress)
final case class WorldInformation(name : String, status : WorldStatus.Value,
diff --git a/common/src/main/scala/services/account/AccountIntermediaryService.scala b/common/src/main/scala/services/account/AccountIntermediaryService.scala
new file mode 100644
index 00000000..babdc73d
--- /dev/null
+++ b/common/src/main/scala/services/account/AccountIntermediaryService.scala
@@ -0,0 +1,64 @@
+// Copyright (c) 2017 PSForever
+package services.account
+
+import scala.collection.mutable
+import akka.actor.Actor
+import net.psforever.objects.Account
+
+/**
+ * This actor is essentially a bridge between the LoginSessionActor and WorldSessionActor. When
+ * a player connects, the LoginSessionActor handles the account login by querying the database,
+ * comparing passwords etc, and then by inserting the account information into this actor by a
+ * token that is also sent to the player's client in the LoginRespMessage packet. The token is
+ * just like a browser cookie session token.
+ *
+ * When the player's client has processed the LoginRespMessage packet, it attempts to connect
+ * to the game world by sending a ConnectToWorldRequestMessage with the token which is used
+ * by the WorldSessionActor to lookup the player's account info from this actor.
+ *
+ * So this actor is a temporary store of account data for players logging into the login
+ * server and crossing over to the world server.
+ */
+
+class AccountIntermediaryService extends Actor {
+ private val accountsByToken = mutable.Map[String, Account]()
+ private val IPAddressBySessionID = mutable.Map[Long, IPAddress]()
+ private [this] val log = org.log4s.getLogger
+
+ override def preStart = {
+ log.trace("Starting...")
+ }
+
+ def receive = {
+ // Called by the LoginSessionActor
+ case StoreAccountData(token, account) =>
+ accountsByToken += (token -> account)
+ log.info(s"Storing intermediary account data for ${account.AccountId}")
+
+ // Called by the WorldSessionActor
+ case RetrieveAccountData(token) =>
+ val account : Option[Account] = accountsByToken.remove(token)
+ if(account.nonEmpty) {
+ sender() ! ReceiveAccountData(account.get)
+ log.info(s"Retrieving intermediary account data for ${account.get.AccountId}")
+ } else {
+ log.error(s"Unable to retrieve intermediary account data for ${account.get.AccountId}")
+ }
+
+ case StoreIPAddress(sessionID, address) =>
+ IPAddressBySessionID += (sessionID -> address)
+ log.info(s"Storing IP address (${address.Address}) for sessionID : $sessionID")
+
+ case RetrieveIPAddress(sessionID) =>
+ val address : Option[IPAddress] = IPAddressBySessionID.remove(sessionID)
+ if(address.nonEmpty) {
+ sender() ! ReceiveIPAddress(address.get)
+ log.info(s"Retrieving IP address data for sessionID : ${sessionID}")
+ } else {
+ log.error(s"Unable to retrieve IP address data for sessionID : ${sessionID}")
+ }
+
+ case msg =>
+ log.warn(s"Unhandled message $msg from $sender")
+ }
+}
diff --git a/common/src/main/scala/services/account/IPAddress.scala b/common/src/main/scala/services/account/IPAddress.scala
new file mode 100644
index 00000000..85e310d1
--- /dev/null
+++ b/common/src/main/scala/services/account/IPAddress.scala
@@ -0,0 +1,11 @@
+// Copyright (c) 2017 PSForever
+package services.account
+
+import java.net.InetSocketAddress
+
+class IPAddress(private val address: InetSocketAddress) {
+ def Address : String = address.getAddress.getHostAddress
+ def CanonicalHostName : String = address.getAddress.getCanonicalHostName
+ def HostName : String = address.getAddress.getHostName
+ def Port : Int = address.getPort
+}
\ No newline at end of file
diff --git a/common/src/main/scala/services/account/ReceiveAccountData.scala b/common/src/main/scala/services/account/ReceiveAccountData.scala
new file mode 100644
index 00000000..a6221a8d
--- /dev/null
+++ b/common/src/main/scala/services/account/ReceiveAccountData.scala
@@ -0,0 +1,6 @@
+// Copyright (c) 2017 PSForever
+package services.account
+
+import net.psforever.objects.Account
+
+final case class ReceiveAccountData(account: Account)
diff --git a/common/src/main/scala/services/account/ReceiveIPAddress.scala b/common/src/main/scala/services/account/ReceiveIPAddress.scala
new file mode 100644
index 00000000..c66890ce
--- /dev/null
+++ b/common/src/main/scala/services/account/ReceiveIPAddress.scala
@@ -0,0 +1,4 @@
+// Copyright (c) 2017 PSForever
+package services.account
+
+final case class ReceiveIPAddress(address : IPAddress)
diff --git a/common/src/main/scala/services/account/RetrieveAccountData.scala b/common/src/main/scala/services/account/RetrieveAccountData.scala
new file mode 100644
index 00000000..1fc699c6
--- /dev/null
+++ b/common/src/main/scala/services/account/RetrieveAccountData.scala
@@ -0,0 +1,4 @@
+// Copyright (c) 2017 PSForever
+package services.account
+
+final case class RetrieveAccountData(token : String)
diff --git a/common/src/main/scala/services/account/RetrieveIPAddress.scala b/common/src/main/scala/services/account/RetrieveIPAddress.scala
new file mode 100644
index 00000000..6afa3e17
--- /dev/null
+++ b/common/src/main/scala/services/account/RetrieveIPAddress.scala
@@ -0,0 +1,4 @@
+// Copyright (c) 2017 PSForever
+package services.account
+
+final case class RetrieveIPAddress(sessionID : Long)
diff --git a/common/src/main/scala/services/account/StoreAccountData.scala b/common/src/main/scala/services/account/StoreAccountData.scala
new file mode 100644
index 00000000..528a40bb
--- /dev/null
+++ b/common/src/main/scala/services/account/StoreAccountData.scala
@@ -0,0 +1,6 @@
+// Copyright (c) 2017 PSForever
+package services.account
+
+import net.psforever.objects.Account
+
+final case class StoreAccountData(token : String, account : Account)
diff --git a/common/src/main/scala/services/account/StoreIPAddress.scala b/common/src/main/scala/services/account/StoreIPAddress.scala
new file mode 100644
index 00000000..9ba51c37
--- /dev/null
+++ b/common/src/main/scala/services/account/StoreIPAddress.scala
@@ -0,0 +1,7 @@
+// Copyright (c) 2017 PSForever
+package services.account
+
+final case class StoreIPAddress(sessionID : Long, address : IPAddress)
+
+
+
diff --git a/config/logback.xml b/config/logback.xml
index 3a0b5f5b..aa7b16bf 100644
--- a/config/logback.xml
+++ b/config/logback.xml
@@ -36,4 +36,7 @@
+
+
+
diff --git a/config/worldserver.ini.dist b/config/worldserver.ini.dist
index 1a240980..3ae061db 100644
--- a/config/worldserver.ini.dist
+++ b/config/worldserver.ini.dist
@@ -18,6 +18,58 @@
# - Line breaks should be at column 100.
###################################################################################################
+###################################################################################################
+# DATABASE SETTINGS
+###################################################################################################
+
+[database]
+
+# Hostname (string)
+# Description: The hostname of the PostgreSQL server.
+# Important: Make sure your database isn't accessible outside of localhost without a
+# strong username and password!
+# Default: "localhost"
+
+Hostname = "localhost"
+
+# Port (int)
+# Description: The TCP port to connect to the database with.
+# Range: [1, 65535] - (TCP port 1, TCP port 65535)
+# Default: 5432 - (Connect to TCP port 5432 -- the default for PostgreSQL)
+
+Port = 5432
+
+# SSL (enum)
+# Description: The SSL configuration of the database connection.
+# Values: Disable - (Do not use SSL -- not recommended for public servers)
+# Prefer - (Try SSL first, but fallback to unencrypted)
+# Require - (Use SSL and fail if it is not enabled or configured correctly
+# Verify - (Use SSL and verify that the certificate is authentic)
+# Default: Prefer
+
+SSL = Prefer
+
+# Database (string)
+# Description: The database name to use on the SQL server.
+# Important: Make sure your username has been granted SELECT and INSERT access to
+# the database.
+# Default: "psforever"
+
+Database = "psforever"
+
+# Username (string)
+# Description: The username to connect to the SQL server with.
+# Default: "psforever"
+
+Username = "psforever"
+
+# Password (string)
+# Description: The password to connect to the SQL server with.
+# Important: CHANGE THIS PASSWORD BEFORE RUNNING A PUBLIC SERVER.
+# Default: "psforever"
+
+Password = "psforever"
+
###################################################################################################
# WORLDSERVER SETTINGS
###################################################################################################
@@ -33,6 +85,29 @@
ListeningPort = 51001
+# Hostname (string)
+# Description: The server's public hostname or IP address. Required when the server is
+# behind a proxy or running in a container/virtual machine.
+# Important: If left as the default, the hostname will be the same as the primary
+# internet interface.
+# Default: ""
+
+Hostname = ""
+
+# ServerName (string)
+# Description: The name of the server as displayed in the server browser.
+# Range: Length = [1, 31]
+# Default: "PSForever"
+
+ServerName = "PSForever"
+
+# ServerType (enum)
+# Description: How the server is displayed in the server browser.
+# Values: Released, Beta, Development
+# Default: Released
+
+ServerType = Released
+
###################################################################################################
# LOGINSERVER SETTINGS
###################################################################################################
@@ -48,6 +123,17 @@ ListeningPort = 51001
ListeningPort = 51000
+# CreateMissingAccounts (boolean)
+# Description: Account usernames that don't exist yet will be automatically created in the
+# database. Useful for test servers and development testing.
+# Important: Not recommended for production servers as typos are easy to make on
+# first registration. Use PSFPortal (https://github.com/psforever/PSFPortal)
+# instead.
+# Default: yes - (Enabled)
+# no - (Disabled)
+
+CreateMissingAccounts = yes
+
###################################################################################################
# NETWORK SETTINGS
###################################################################################################
@@ -94,6 +180,7 @@ Session.OutboundGraceTime = 1 minute
# the memory load of the server and it will (by design) affect performance.
# Default: no - (Disabled)
# yes - (Enabled)
+
NetSim.Active = no
# NetSim.Loss (float)
diff --git a/pslogin/src/main/scala/Database.scala b/pslogin/src/main/scala/Database.scala
new file mode 100644
index 00000000..c906af78
--- /dev/null
+++ b/pslogin/src/main/scala/Database.scala
@@ -0,0 +1,68 @@
+// Copyright (c) 2017 PSForever
+import com.github.mauricio.async.db.postgresql.PostgreSQLConnection
+import com.github.mauricio.async.db.{Configuration, QueryResult, RowData, SSLConfiguration}
+import scala.util.{Try,Success,Failure}
+import scala.concurrent._
+import scala.concurrent.duration._
+import ExecutionContext.Implicits.global
+
+import scala.concurrent.Future
+
+object Database {
+ private val logger = org.log4s.getLogger
+
+ import WorldConfig.ConfigDatabaseSSL._
+
+ val UNKNOWN_ERROR : Int = -1
+ val EMPTY_RESULT : Int = -2
+ val config = Configuration(
+ WorldConfig.Get[String]("database.Username"),
+ WorldConfig.Get[String]("database.Hostname"),
+ WorldConfig.Get[Int]("database.Port"),
+ Some(WorldConfig.Get[String]("database.Password")),
+ Some(WorldConfig.Get[String]("database.Database")),
+ WorldConfig.Get[WorldConfig.ConfigDatabaseSSL.Value]("database.SSL") match {
+ case Disable => SSLConfiguration(SSLConfiguration.Mode.Disable)
+ case Prefer => SSLConfiguration(SSLConfiguration.Mode.Prefer)
+ case Require => SSLConfiguration(SSLConfiguration.Mode.Require)
+ // not including VerifyCA as full is more secure
+ case Verify => SSLConfiguration(SSLConfiguration.Mode.VerifyFull)
+ }
+ )
+
+ def testConnection : Try[Boolean] = {
+ try {
+ val connection = Await.result(getConnection.connect, 2 seconds)
+ val result = Await.result(query(connection.sendQuery("SELECT 0")), 2 seconds)
+ connection.disconnect
+ Success(true)
+ } catch {
+ case e : com.github.mauricio.async.db.postgresql.exceptions.GenericDatabaseException =>
+ logger.error(e.errorMessage.message)
+ Failure(e)
+ case e : javax.net.ssl.SSLHandshakeException =>
+ logger.error(s"${e.getMessage} (make sure your database supports SSL and the certificate matches)")
+ Failure(e)
+ case e : Throwable =>
+ logger.error(s"Unknown database error: ${e.toString}")
+ Failure(e)
+ }
+ }
+
+ // TODO Will probably want to use the ConnectionPool, although I don't know the implications for multithreaded apps
+ def getConnection = new PostgreSQLConnection(config)
+
+ def query(query : Future[QueryResult]) : Future[Any] = {
+ query.map(queryResult => queryResult.rows match {
+ case Some(resultSet) =>
+ if(resultSet.nonEmpty) {
+ val row : RowData = resultSet.head
+ row
+ } else {
+ EMPTY_RESULT
+ }
+ case None =>
+ UNKNOWN_ERROR
+ })
+ }
+}
diff --git a/pslogin/src/main/scala/DatabaseConnector.scala b/pslogin/src/main/scala/DatabaseConnector.scala
deleted file mode 100644
index 9801deba..00000000
--- a/pslogin/src/main/scala/DatabaseConnector.scala
+++ /dev/null
@@ -1,9 +0,0 @@
-// Copyright (c) 2017 PSForever
-import com.github.mauricio.async.db.Connection
-import com.github.mauricio.async.db.mysql.MySQLConnection
-import com.github.mauricio.async.db.mysql.util.URLParser
-
-object DatabaseConnector {
- val accounts_db = URLParser.parse("jdbc:mysql://localhost:3306/psforever-accounts?user=root&password=PSForever")
- def getAccountsConnection = new MySQLConnection(accounts_db)
-}
diff --git a/pslogin/src/main/scala/LoginSessionActor.scala b/pslogin/src/main/scala/LoginSessionActor.scala
index 55e234d6..ff7e0ea7 100644
--- a/pslogin/src/main/scala/LoginSessionActor.scala
+++ b/pslogin/src/main/scala/LoginSessionActor.scala
@@ -1,5 +1,6 @@
// Copyright (c) 2017 PSForever
import java.net.InetSocketAddress
+import java.net.InetAddress
import akka.actor.{Actor, ActorRef, Cancellable, MDCContextAware}
import net.psforever.packet.{PlanetSideGamePacket, _}
@@ -8,13 +9,24 @@ import net.psforever.packet.game._
import org.log4s.MDC
import scodec.bits._
import MDCContextAware.Implicits._
-import com.github.mauricio.async.db.{Connection, QueryResult, RowData}
-import com.github.mauricio.async.db.mysql.exceptions.MySQLException
+import com.github.mauricio.async.db.general.ArrayRowData
+import com.github.mauricio.async.db.{Connection, QueryResult}
+import net.psforever.objects.Account
import net.psforever.objects.DefaultCancellable
import net.psforever.types.PlanetSideEmpire
+import services.ServiceManager
+import services.ServiceManager.Lookup
+import services.account.{ReceiveIPAddress, RetrieveIPAddress, StoreAccountData}
+import com.github.t3hnar.bcrypt._
+import net.psforever.packet.game.LoginRespMessage.{LoginError, StationError, StationSubscriptionStatus}
-import scala.concurrent.{Await, Future}
import scala.concurrent.duration._
+import scala.util.{Failure, Success}
+
+case class StartAccountAuthentication(connection: Option[Connection], username: String, password: String, newToken: String, queryResult: Any)
+case class FinishAccountLogin(connection: Option[Connection], username: String, newToken: String, isSuccessfulLogin: Boolean, isInactive:Boolean = false)
+case class CreateNewAccount(connection: Option[Connection], username: String, password: String, newToken: String)
+case class LogTheLoginOccurrence(connection: Option[Connection], username: String, newToken: String, isSuccessfulLogin: Boolean, accountId: Int)
class LoginSessionActor extends Actor with MDCContextAware {
private[this] val log = org.log4s.getLogger
@@ -25,9 +37,28 @@ class LoginSessionActor extends Actor with MDCContextAware {
var sessionId : Long = 0
var leftRef : ActorRef = ActorRef.noSender
var rightRef : ActorRef = ActorRef.noSender
+ var accountIntermediary : ActorRef = Actor.noSender
var updateServerListTask : Cancellable = DefaultCancellable.obj
+ var ipAddress : String = ""
+ var hostName : String = ""
+ var canonicalHostName : String = ""
+ var port : Int = 0
+
+ val serverName = WorldConfig.Get[String]("worldserver.ServerName");
+
+ // This MUST be an IP address. The client DOES NOT do name resolution
+ var serverHost : String = if (WorldConfig.Get[String]("worldserver.Hostname") != "")
+ InetAddress.getByName(WorldConfig.Get[String]("worldserver.Hostname")).getHostAddress
+ else
+ LoginConfig.serverIpAddress.getHostAddress
+
+ val serverAddress = new InetSocketAddress(serverHost, WorldConfig.Get[Int]("worldserver.ListeningPort"))
+
+ // Reference: https://stackoverflow.com/a/50470009
+ private val numBcryptPasses = 10
+
override def postStop() = {
if(updateServerListTask != null)
updateServerListTask.cancel()
@@ -46,6 +77,7 @@ class LoginSessionActor extends Actor with MDCContextAware {
rightRef = sender()
}
context.become(Started)
+ ServiceManager.serviceManager ! Lookup("accountIntermediary")
case _ =>
log.error("Unknown message")
@@ -53,6 +85,13 @@ class LoginSessionActor extends Actor with MDCContextAware {
}
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) =>
@@ -76,98 +115,239 @@ class LoginSessionActor extends Actor with MDCContextAware {
}
}
- // TODO: move to global configuration or database lookup
- val serverName = "PSForever"
- val serverAddress = new InetSocketAddress(LoginConfig.serverIpAddress.getHostAddress, 51001)
+ 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
- // TESTING CODE FOR ACCOUNT LOOKUP
- def accountLookup(username : String, password : String) : Boolean = {
- val connection: Connection = DatabaseConnector.getAccountsConnection
- Await.result(connection.connect, 5 seconds)
+ val clientVersion = s"Client Version: $majorVersion.$minorVersion.$revision, $buildDate"
- // create account
- // username, password, email
- // Result: worked or failed
- // login to account
- // username, password
- // Result: token (session cookie)
+ accountIntermediary ! RetrieveIPAddress(sessionId)
- val future: Future[QueryResult] = connection.sendPreparedStatement("SELECT * FROM accounts where username=?", Array(username))
+ 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")
+ }
- val mapResult: Future[Any] = future.map(queryResult => queryResult.rows match {
- case Some(resultSet) =>
- val row : RowData = resultSet.head
- row(0)
- case None =>
- -1
- })
+ startAccountLogin(username, password.get)
- try {
- // XXX: remove awaits
- Await.result( mapResult, 5 seconds )
- return true
- } catch {
- case e : MySQLException =>
- log.error(s"SQL exception $e")
- case e: Exception =>
- log.error(s"Unknown exception when executing SQL statement: $e")
- } finally {
- connection.disconnect
- }
- false
+ case ConnectToWorldRequestMessage(name, _, _, _, _, _, _) =>
+ log.info(s"Connect to world request for '$name'")
+ val response = ConnectToWorldMessage(serverName, serverAddress.getHostString, serverAddress.getPort)
+ sendResponse(PacketCoding.CreateGamePacket(0, response))
+ sendResponse(DropSession(sessionId, "user transferring to world"))
+
+ case _ =>
+ log.debug(s"Unhandled GamePacket $pkt")
}
- 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
- import game.LoginRespMessage._
-
- val clientVersion = s"Client Version: $majorVersion.$minorVersion.$revision, $buildDate"
-
- if(token.isDefined)
- log.info(s"New login UN:$username Token:${token.get}. $clientVersion")
- else
- log.info(s"New login UN:$username. $clientVersion")
-
- // This is temporary until a schema has been developed
- //val loginSucceeded = accountLookup(username, password.getOrElse(token.get))
-
- // Allow any one to login for now
- val loginSucceeded = true
-
- if(loginSucceeded) {
- val newToken = token.getOrElse("AAAABBBBCCCCDDDDEEEEFFFFGGGGHHH")
- val response = LoginRespMessage(newToken, LoginError.Success, StationError.AccountActive,
- StationSubscriptionStatus.Active, 0, username, 10001)
-
- sendResponse(PacketCoding.CreateGamePacket(0, response))
-
- updateServerListTask = context.system.scheduler.schedule(0 seconds, 2 seconds, self, UpdateServerList())
- } else {
- val newToken = token.getOrElse("AAAABBBBCCCCDDDDEEEEFFFFGGGGHHH")
- val response = LoginRespMessage(newToken, LoginError.BadUsernameOrPassword, StationError.AccountActive,
- StationSubscriptionStatus.Active, 685276011, username, 10001)
-
- log.info(s"Failed login to account $username")
- sendResponse(PacketCoding.CreateGamePacket(0, response))
+ def startAccountLogin(username: String, password: String) = {
+ val newToken = this.generateToken()
+ Database.getConnection.connect.onComplete {
+ case Success(connection) =>
+ Database.query(connection.sendPreparedStatement(
+ "SELECT id, passhash, inactive, gm FROM accounts where username=?", Array(username)
+ )).onComplete {
+ case Success(queryResult) =>
+ context.become(startAccountAuthentication)
+ self ! StartAccountAuthentication(Some(connection), username, password, newToken, queryResult)
+ case Failure(e) =>
+ log.error("Failed account lookup query " + e.getMessage)
+ connection.disconnect
+ context.become(finishAccountLogin)
+ self ! FinishAccountLogin(Some(connection), username, newToken, false)
}
+ case Failure(e) =>
+ log.error("Failed connecting to database for account lookup " + e.getMessage)
+ context.become(finishAccountLogin)
+ self ! FinishAccountLogin(None, username, newToken, false)
+ }
+ }
- case ConnectToWorldRequestMessage(name, _, _, _, _, _, _) =>
- log.info(s"Connect to world request for '$name'")
- val response = ConnectToWorldMessage(serverName, serverAddress.getHostString, serverAddress.getPort)
- sendResponse(PacketCoding.CreateGamePacket(0, response))
- sendResponse(DropSession(sessionId, "user transferring to world"))
+ def startAccountAuthentication() : Receive = {
+ case StartAccountAuthentication(connection, username, password, newToken, queryResult) =>
+ queryResult match {
- case _ =>
- log.debug(s"Unhandled GamePacket $pkt")
+ // If we got a row from the database
+ case row: ArrayRowData =>
+ val (isSuccessfulLogin, accountId) = authenticateExistingAccount(connection.get, username, password, newToken, row)
+ if(isSuccessfulLogin) { // login OK
+ context.become(logTheLoginOccurrence)
+ self ! LogTheLoginOccurrence(connection, username, newToken, isSuccessfulLogin, accountId)
+ } else {
+ if (accountId == 0) { // Bad password
+ context.become(finishAccountLogin)
+ self ! FinishAccountLogin(connection, username, newToken, isSuccessfulLogin)
+ } else { // Account inactive
+ context.become(finishAccountLogin)
+ self ! FinishAccountLogin(connection, username, newToken, isSuccessfulLogin, true)
+ }
+ }
+
+ // If the account didn't exist in the database
+ case errorCode: Int => errorCode match {
+ case Database.EMPTY_RESULT =>
+ if (WorldConfig.Get[Boolean]("loginserver.CreateMissingAccounts")) {
+ self ! CreateNewAccount(connection, username, password, newToken)
+ context.become(createNewAccount)
+ } else {
+ context.become(finishAccountLogin)
+ self ! FinishAccountLogin(connection, username, newToken, false)
+ }
+
+ case _ =>
+ log.error(s"Issue retrieving result set from database for account $username")
+ context.become(finishAccountLogin)
+ self ! FinishAccountLogin(connection, username, newToken, false)
+ }
+ }
+ case default => failWithError(s"Invalid message '$default' received in startAccountAuthentication")
+ }
+
+ def createNewAccount() : Receive = {
+ case CreateNewAccount(connection, username, password, newToken) =>
+ log.info(s"Account $username does not exist, creating new account...")
+ val bcryptPassword : String = password.bcrypt(numBcryptPasses)
+
+ connection.get.inTransaction {
+ c => c.sendPreparedStatement(
+ "INSERT INTO accounts (username, passhash) VALUES(?,?) RETURNING id",
+ Array(username, bcryptPassword)
+ )
+ }.onComplete {
+ case Success(insertResult) =>
+ insertResult match {
+ case result: QueryResult =>
+ if (result.rows.nonEmpty) {
+ val accountId = result.rows.get.head(0).asInstanceOf[Int]
+ accountIntermediary ! StoreAccountData(newToken, new Account(accountId, username))
+ log.info(s"Successfully created new account for $username")
+ context.become(logTheLoginOccurrence)
+ self ! LogTheLoginOccurrence(connection, username, newToken, true, accountId)
+ } else {
+ log.error(s"No result from account create insert for $username")
+ context.become(finishAccountLogin)
+ self ! FinishAccountLogin(connection, username, newToken, false)
+ }
+ case _ =>
+ log.error(s"Error creating new account for $username")
+ context.become(finishAccountLogin)
+ self ! FinishAccountLogin(connection, username, newToken, false)
+ }
+ case _ => failWithError("Something to do ?")
+ }
+ case default => failWithError(s"Invalid message '$default' received in createNewAccount")
+ }
+
+ // Essentially keeps a record of this individual login occurrence
+ def logTheLoginOccurrence() : Receive = {
+ case LogTheLoginOccurrence(connection, username, newToken, isSuccessfulLogin, accountId) =>
+ connection.get.inTransaction {
+ c => c.sendPreparedStatement(
+ "INSERT INTO logins (account_id, login_time, ip_address, canonical_hostName, hostname, port) VALUES(?,?,?,?,?,?)",
+ Array(accountId, new java.sql.Timestamp(System.currentTimeMillis), ipAddress, canonicalHostName, hostName, port)
+ )
+ }.onComplete {
+ case _ =>
+ context.become(finishAccountLogin)
+ self ! FinishAccountLogin(connection, username, newToken, isSuccessfulLogin)
+ }
+ case default => failWithError(s"Invalid message '$default' received in logTheLoginOccurrence")
+ }
+
+ def finishAccountLogin() : Receive = {
+ case FinishAccountLogin(connection, username, newToken, isSuccessfulLogin, isInactive) =>
+ if(isSuccessfulLogin) { // Login OK
+ loginSuccessfulResponse(username, newToken)
+ updateServerListTask = context.system.scheduler.schedule(0 seconds, 2 seconds, self, UpdateServerList())
+ } else {
+ if (!isInactive && connection.nonEmpty) { // Bad Password
+ loginPwdFailureResponse(username, newToken)
+ } else if (connection.nonEmpty) { // Account inactive
+ loginAccountFailureResponse(username, newToken)
+ } else {
+ loginFailureResponse(username, newToken)
+ }
+ }
+ if(connection.nonEmpty) {
+ connection.get.disconnect
+ }
+ context.become(Started)
+ case default =>
+ failWithError(s"Invalid message '$default' received in finishAccountLogin")
+ }
+
+ def authenticateExistingAccount(
+ connection: Connection, username: String, password: String, newToken: String, row: ArrayRowData
+ ) : (Boolean, Int) = {
+ val accountId : Int = row(0).asInstanceOf[Int]
+ val dbPassHash : String = row(1).asInstanceOf[String]
+ val inactive : Boolean = row(2).asInstanceOf[Boolean]
+ val gm : Boolean = row(3).asInstanceOf[Boolean]
+
+ if (password.isBcrypted(dbPassHash)) {
+ if (!inactive) {
+ log.info(s"Account password correct for $username!")
+ accountIntermediary ! StoreAccountData(newToken, new Account(accountId, username, gm))
+ return (true, accountId)
+ } else {
+ log.info(s"Account password correct for $username but account inactive !")
+ return (false, accountId)
+ }
+ } else {
+ log.info(s"Account password incorrect for $username")
+ }
+
+ (false, 0)
+ }
+
+ 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 (i <- 1 to 31) {
+ sb.append(r.nextPrintableChar)
+ }
+ sb.toString
}
def updateServerList() = {
val msg = VNLWorldStatusMessage("Welcome to PlanetSide! ",
Vector(
WorldInformation(
- serverName, WorldStatus.Up, ServerType.Beta, Vector(WorldConnectionInfo(serverAddress)), PlanetSideEmpire.VS
+ serverName, WorldStatus.Up,
+ WorldConfig.Get[ServerType.Value]("worldserver.ServerType"), Vector(WorldConnectionInfo(serverAddress)), PlanetSideEmpire.VS
)
)
)
diff --git a/pslogin/src/main/scala/PsLogin.scala b/pslogin/src/main/scala/PsLogin.scala
index f7bb24e1..deb52fad 100644
--- a/pslogin/src/main/scala/PsLogin.scala
+++ b/pslogin/src/main/scala/PsLogin.scala
@@ -19,6 +19,7 @@ import org.slf4j
import org.fusesource.jansi.Ansi._
import org.fusesource.jansi.Ansi.Color._
import services.ServiceManager
+import services.account.AccountIntermediaryService
import services.chat.ChatService
import services.galaxy.GalaxyService
import services.teamwork.SquadService
@@ -200,6 +201,13 @@ object PsLogin {
sys.exit(1)
}
+ Database.testConnection match {
+ case scala.util.Failure(e) =>
+ logger.error("Unable to connect to the database")
+ sys.exit(1)
+ case _ =>
+ }
+
logger.info("Starting actor subsystems...")
/** Make sure we capture Akka messages (but only INFO and above)
@@ -255,6 +263,7 @@ object PsLogin {
val continentList = createContinents()
val serviceManager = ServiceManager.boot
+ serviceManager ! ServiceManager.Register(Props[AccountIntermediaryService], "accountIntermediary")
serviceManager ! ServiceManager.Register(RandomPool(50).props(Props[TaskResolver]), "taskResolver")
serviceManager ! ServiceManager.Register(Props[ChatService], "chat")
serviceManager ! ServiceManager.Register(Props[GalaxyService], "galaxy")
diff --git a/pslogin/src/main/scala/SessionRouter.scala b/pslogin/src/main/scala/SessionRouter.scala
index 890512dd..8777def9 100644
--- a/pslogin/src/main/scala/SessionRouter.scala
+++ b/pslogin/src/main/scala/SessionRouter.scala
@@ -11,6 +11,9 @@ import akka.actor.MDCContextAware.MdcMsg
import akka.actor.SupervisorStrategy.Stop
import net.psforever.packet.PacketCoding
import net.psforever.packet.control.ConnectionClose
+import services.ServiceManager
+import services.ServiceManager.Lookup
+import services.account.{IPAddress, StoreIPAddress}
import scala.concurrent.duration._
@@ -46,6 +49,7 @@ class SessionRouter(role : String, pipeline : List[SessionPipeline]) extends Act
val sessionById = mutable.Map[Long, Session]()
val sessionByActor = mutable.Map[ActorRef, Session]()
val closePacket = PacketCoding.EncodePacket(ConnectionClose()).require.bytes
+ var accountIntermediary : ActorRef = Actor.noSender
var sessionId = 0L // this is a connection session, not an actual logged in session ID
var inputRef : ActorRef = ActorRef.noSender
@@ -62,6 +66,7 @@ class SessionRouter(role : String, pipeline : List[SessionPipeline]) extends Act
case Hello() =>
inputRef = sender()
context.become(started)
+ ServiceManager.serviceManager ! Lookup("accountIntermediary")
case default =>
log.error(s"Unknown message $default. Stopping...")
context.stop(self)
@@ -72,6 +77,8 @@ class SessionRouter(role : String, pipeline : List[SessionPipeline]) extends Act
}
def started : Receive = {
+ case ServiceManager.LookupResult("accountIntermediary", endpoint) =>
+ accountIntermediary = endpoint
case recv @ ReceivedPacket(msg, from) =>
var session : Session = null
@@ -155,6 +162,10 @@ class SessionRouter(role : String, pipeline : List[SessionPipeline]) extends Act
log.info(s"New session ID=${id} from " + address.toString)
+ if(role == "Login") {
+ accountIntermediary ! StoreIPAddress(id, new IPAddress(address))
+ }
+
session
}
diff --git a/pslogin/src/main/scala/WorldConfig.scala b/pslogin/src/main/scala/WorldConfig.scala
index baca9d02..82c09ecd 100644
--- a/pslogin/src/main/scala/WorldConfig.scala
+++ b/pslogin/src/main/scala/WorldConfig.scala
@@ -1,16 +1,31 @@
// Copyright (c) 2019 PSForever
+import scala.util.matching.Regex
import net.psforever.config._
import scala.concurrent.duration._
+import net.psforever.packet.game._
object WorldConfig extends ConfigParser {
- protected var config_map : Map[String, Any] = Map()
+ // hostname, but allow for empty string
+ protected val hostname_pattern = Constraints.pattern(raw"^((([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9]))|()$$".r, "hostname")
protected val config_template = Seq(
+ ConfigSection("database",
+ ConfigEntryString("Hostname", "localhost", hostname_pattern, Constraints.minLength(1)),
+ ConfigEntryInt("Port", 5432, Constraints.min(1), Constraints.max(65535)),
+ ConfigEntryEnum[ConfigDatabaseSSL.type]("SSL", ConfigDatabaseSSL.Prefer),
+ ConfigEntryString("Database", "psforever", Constraints.minLength(1)),
+ ConfigEntryString("Username", "psforever", Constraints.minLength(1)),
+ ConfigEntryString("Password", "psforever", Constraints.minLength(1))
+ ),
ConfigSection("loginserver",
- ConfigEntryInt("ListeningPort", 51000, Constraints.min(1), Constraints.max(65535))
+ ConfigEntryInt("ListeningPort", 51000, Constraints.min(1), Constraints.max(65535)),
+ ConfigEntryBool("CreateMissingAccounts", true)
),
ConfigSection("worldserver",
- ConfigEntryInt("ListeningPort", 51001, Constraints.min(1), Constraints.max(65535))
+ ConfigEntryInt("ListeningPort", 51001, Constraints.min(1), Constraints.max(65535)),
+ ConfigEntryString("Hostname", "", hostname_pattern),
+ ConfigEntryString("ServerName", "PSForever", Constraints.minLength(1), Constraints.maxLength(31)),
+ ConfigEntryEnum[ServerType.type]("ServerType", ServerType.Released)
),
ConfigSection("network",
ConfigEntryTime("Session.InboundGraceTime", 1 minute, Constraints.min(10 seconds)),
@@ -25,6 +40,11 @@ object WorldConfig extends ConfigParser {
)
)
+ object ConfigDatabaseSSL extends Enumeration {
+ type Type = Value
+ val Disable, Prefer, Require, Verify = Value
+ }
+
override def postParseChecks : ValidationResult = {
var errors : Invalid = Invalid("")
diff --git a/pslogin/src/main/scala/WorldSessionActor.scala b/pslogin/src/main/scala/WorldSessionActor.scala
index ec50acdd..85f47b1d 100644
--- a/pslogin/src/main/scala/WorldSessionActor.scala
+++ b/pslogin/src/main/scala/WorldSessionActor.scala
@@ -10,6 +10,8 @@ import scodec.Attempt.{Failure, Successful}
import scodec.bits._
import org.log4s.{Logger, MDC}
import MDCContextAware.Implicits._
+import com.github.mauricio.async.db.general.ArrayRowData
+import com.github.mauricio.async.db.{Connection, QueryResult}
import csr.{CSRWarp, CSRZone, Traveler}
import net.psforever.objects.GlobalDefinitions._
import services.ServiceManager.Lookup
@@ -47,7 +49,8 @@ import net.psforever.objects.zones.{InterstellarCluster, Zone, ZoneHotSpotProjec
import net.psforever.packet.game.objectcreate._
import net.psforever.packet.game.{HotSpotInfo => PacketHotSpotInfo}
import net.psforever.types._
-import services.{RemoverActor, vehicle, _}
+import services._
+import services.account.{ReceiveAccountData, RetrieveAccountData}
import services.avatar.{AvatarAction, AvatarResponse, AvatarServiceMessage, AvatarServiceResponse}
import services.galaxy.{GalaxyAction, GalaxyResponse, GalaxyServiceMessage, GalaxyServiceResponse}
import services.local.{LocalAction, LocalResponse, LocalServiceMessage, LocalServiceResponse}
@@ -83,12 +86,14 @@ class WorldSessionActor extends Actor
var sessionId : Long = 0
var leftRef : ActorRef = ActorRef.noSender
var rightRef : ActorRef = ActorRef.noSender
+ var accountIntermediary : ActorRef = ActorRef.noSender
var chatService: ActorRef = ActorRef.noSender
var galaxyService : ActorRef = ActorRef.noSender
var squadService : ActorRef = ActorRef.noSender
var taskResolver : ActorRef = Actor.noSender
var cluster : ActorRef = Actor.noSender
var continent : Zone = Zone.Nowhere
+ var account : Account = null
var player : Player = null
var avatar : Avatar = null
var progressBarValue : Option[Float] = None
@@ -96,10 +101,12 @@ class WorldSessionActor extends Actor
var prefire : Option[PlanetSideGUID] = None //if WeaponFireMessage precedes ChangeFireStateMessage_Start
var shotsWhileDead : Int = 0
var accessedContainer : Option[PlanetSideGameObject with Container] = None
+ var connectionState : Int = 25
var flying : Boolean = false
var speed : Float = 1.0f
var spectator : Boolean = false
var admin : Boolean = false
+ var loadConfZone : Boolean = false
var noSpawnPointHere : Boolean = false
var usingMedicalTerminal : Option[PlanetSideGUID] = None
var controlled : Option[Int] = None
@@ -313,6 +320,7 @@ class WorldSessionActor extends Actor
rightRef = sender()
}
context.become(Started)
+ ServiceManager.serviceManager ! Lookup("accountIntermediary")
ServiceManager.serviceManager ! Lookup("chat")
ServiceManager.serviceManager ! Lookup("taskResolver")
ServiceManager.serviceManager ! Lookup("cluster")
@@ -325,6 +333,9 @@ class WorldSessionActor extends Actor
}
def Started : Receive = jammableBehavior.orElse {
+ case ServiceManager.LookupResult("accountIntermediary", endpoint) =>
+ accountIntermediary = endpoint
+ log.info("ID: " + sessionId + " Got account intermediary service " + endpoint)
case ServiceManager.LookupResult("chat", endpoint) =>
chatService = endpoint
log.info("ID: " + sessionId + " Got chat service " + endpoint)
@@ -698,34 +709,110 @@ class WorldSessionActor extends Actor
case CheckCargoMounting(cargo_guid, carrier_guid, mountPoint, iteration) =>
HandleCheckCargoMounting(cargo_guid, carrier_guid, mountPoint, iteration)
- case ListAccountCharacters =>
- import net.psforever.objects.definition.converter.CharacterSelectConverter
- val gen : AtomicInteger = new AtomicInteger(1)
- val converter : CharacterSelectConverter = new CharacterSelectConverter
- //load characters
- SetCharacterSelectScreenGUID(player, gen)
- val health = player.Health
- val stamina = player.Stamina
- val armor = player.Armor
- player.Spawn
- sendResponse(
- ObjectCreateDetailedMessage(ObjectClass.avatar, player.GUID, converter.DetailedConstructorData(player).get)
- )
- if(health > 0) {
- //player can not be dead; stay spawned as alive
- player.Health = health
- player.Stamina = stamina
- player.Armor = armor
+ case CreateCharacter(connection, name, head, voice, gender, empire) =>
+ log.info(s"Creating new character $name...")
+ val accountUserName : String = account.Username
+
+ connection.get.inTransaction {
+ c => c.sendPreparedStatement(
+ "INSERT INTO characters (name, account_id, faction_id, gender_id, head_id, voice_id) VALUES(?,?,?,?,?,?) RETURNING id",
+ Array(name, account.AccountId, empire.id, gender.id, head, voice.id)
+ )
+ }.onComplete {
+ case Success(insertResult) =>
+ insertResult match {
+ case result: QueryResult =>
+ if (result.rows.nonEmpty) {
+ log.info(s"Successfully created new character for $accountUserName")
+ sendResponse(ActionResultMessage.Pass)
+ self ! ListAccountCharacters(connection)
+ } else {
+ log.error(s"Error creating new character for $accountUserName")
+ sendResponse(ActionResultMessage.Fail(0))
+ self ! ListAccountCharacters(connection)
+ }
+ case _ =>
+ log.error(s"Error creating new character for $accountUserName")
+ sendResponse(ActionResultMessage.Fail(3))
+ self ! ListAccountCharacters(connection)
+ }
+ case _ => failWithError("Something to do ?")
}
- sendResponse(CharacterInfoMessage(15, PlanetSideZoneID(10000), avatar.CharId, player.GUID, false, 6404428))
- RemoveCharacterSelectScreenGUID(player)
- sendResponse(CharacterInfoMessage(0, PlanetSideZoneID(1), 0, PlanetSideGUID(0), true, 0))
- sendResponse(CharacterInfoMessage(0, PlanetSideZoneID(1), 0, PlanetSideGUID(0), true, 0))
+
+ case ListAccountCharacters(connection) =>
+ val accountUserName : String = account.Username
+
+ StartBundlingPackets()
+ connection.get.sendPreparedStatement(
+ "SELECT id, name, faction_id, gender_id, head_id, voice_id, deleted, last_login FROM characters where account_id=? ORDER BY last_login", Array(account.AccountId)
+ ).onComplete {
+ case Success(queryResult) =>
+ queryResult match {
+ case result: QueryResult =>
+ if (result.rows.nonEmpty) {
+ import net.psforever.objects.definition.converter.CharacterSelectConverter
+ val gen : AtomicInteger = new AtomicInteger(1)
+ val converter : CharacterSelectConverter = new CharacterSelectConverter
+
+ result.rows foreach{ row =>
+ log.info(s"char list : ${row.toString()}")
+ val nowTimeInSeconds = System.currentTimeMillis()/1000
+ var avatarArray:Array[Avatar] = Array.ofDim(row.length)
+ var playerArray:Array[Player] = Array.ofDim(row.length)
+ row.zipWithIndex.foreach{ case (value,i) =>
+ val lName : String = value(1).asInstanceOf[String]
+ val lFaction : PlanetSideEmpire.Value = PlanetSideEmpire(value(2).asInstanceOf[Int])
+ val lGender : CharacterGender.Value = CharacterGender(value(3).asInstanceOf[Int])
+ val lHead : Int = value(4).asInstanceOf[Int]
+ val lVoice : CharacterVoice.Value = CharacterVoice(value(5).asInstanceOf[Int])
+ val lDeleted : Boolean = value(6).asInstanceOf[Boolean]
+ val lTime = value(7).asInstanceOf[org.joda.time.LocalDateTime].toDateTime().getMillis()/1000
+ val secondsSinceLastLogin = nowTimeInSeconds - lTime
+ if (!lDeleted) {
+ avatarArray(i) = new Avatar(value(0).asInstanceOf[Int], lName, lFaction, lGender, lHead, lVoice)
+ AwardBattleExperiencePoints(avatarArray(i), 20000000L)
+ avatarArray(i).CEP = 600000
+ playerArray(i) = new Player(avatarArray(i))
+ playerArray(i).ExoSuit = ExoSuitType.Reinforced
+ playerArray(i).Slot(0).Equipment = Tool(StandardPistol(playerArray(i).Faction))
+ playerArray(i).Slot(1).Equipment = Tool(MediumPistol(playerArray(i).Faction))
+ playerArray(i).Slot(2).Equipment = Tool(HeavyRifle(playerArray(i).Faction))
+ playerArray(i).Slot(3).Equipment = Tool(AntiVehicularLauncher(playerArray(i).Faction))
+ playerArray(i).Slot(4).Equipment = Tool(katana)
+ SetCharacterSelectScreenGUID(playerArray(i), gen)
+ val health = playerArray(i).Health
+ val stamina = playerArray(i).Stamina
+ val armor = playerArray(i).Armor
+ playerArray(i).Spawn
+ sendResponse(ObjectCreateDetailedMessage(ObjectClass.avatar, playerArray(i).GUID, converter.DetailedConstructorData(playerArray(i)).get))
+ if (health > 0) { //player can not be dead; stay spawned as alive
+ playerArray(i).Health = health
+ playerArray(i).Stamina = stamina
+ playerArray(i).Armor = armor
+ }
+ sendResponse(CharacterInfoMessage(15, PlanetSideZoneID(4), value(0).asInstanceOf[Int], playerArray(i).GUID, false, secondsSinceLastLogin))
+ RemoveCharacterSelectScreenGUID(playerArray(i))
+ }
+ }
+ sendResponse(CharacterInfoMessage(0, PlanetSideZoneID(1), 0, PlanetSideGUID(0), true, 0))
+ }
+ Thread.sleep(50)
+ if(connection.nonEmpty) connection.get.disconnect
+ } else {
+ log.info("dunno")
+ }
+ case _ =>
+ log.error(s"Error listing character(s) for account $accountUserName")
+ }
+ case _ => failWithError("Something to do ?")
+ }
+ StopBundlingPackets()
case VehicleLoaded(_ /*vehicle*/) => ;
//currently being handled by VehicleSpawnPad.LoadVehicle during testing phase
case Zone.ClientInitialization(zone) =>
+ Thread.sleep(connectionState)
val continentNumber = zone.Number
val poplist = zone.Players
val popBO = 0
@@ -749,6 +836,7 @@ class WorldSessionActor extends Actor
sendResponse(ZoneInfoMessage(continentNumber, true, 0))
sendResponse(ZoneLockInfoMessage(continentNumber, false, true))
sendResponse(ZoneForcedCavernConnectionsMessage(continentNumber, 0))
+
sendResponse(HotSpotUpdateMessage(
continentNumber,
1,
@@ -756,6 +844,7 @@ class WorldSessionActor extends Actor
.map { spot => PacketHotSpotInfo(spot.DisplayLocation.x, spot.DisplayLocation.y, 40) }
)) //normally set for all zones in bulk; should be fine manually updating per zone like this
+ StopBundlingPackets()
case Zone.Population.PlayerHasLeft(zone, None) =>
log.info(s"$avatar does not have a body on ${zone.Id}")
@@ -1027,9 +1116,9 @@ class WorldSessionActor extends Actor
taskResolver ! GUIDTask.UnregisterObjectTask(obj)(continent.GUID)
case InterstellarCluster.ClientInitializationComplete() =>
- StopBundlingPackets()
LivePlayerList.Add(sessionId, avatar)
traveler = new Traveler(self, continent.Id)
+ StartBundlingPackets()
//PropertyOverrideMessage
sendResponse(PlanetsideAttributeMessage(PlanetSideGUID(0), 112, 0)) // disable festive backpacks
sendResponse(ReplicationStreamMessage(5, Some(6), Vector.empty)) //clear squad list
@@ -1048,10 +1137,11 @@ class WorldSessionActor extends Actor
squadService ! Service.Join(s"${avatar.faction}") //channel will be player.Faction
squadService ! Service.Join(s"${avatar.CharId}") //channel will be player.CharId (in order to work with packets)
//go home
- cluster ! InterstellarCluster.GetWorld("home3")
+ cluster ! InterstellarCluster.GetWorld("z6")
case InterstellarCluster.GiveWorld(zoneId, zone) =>
log.info(s"Zone $zoneId will now load")
+ loadConfZone = true
continent.AvatarEvents ! Service.Leave()
continent.LocalEvents ! Service.Leave()
continent.VehicleEvents ! Service.Leave()
@@ -1134,6 +1224,36 @@ class WorldSessionActor extends Actor
//log.info(s"Received a direct message: $pkt")
sendResponse(pkt)
+ case ReceiveAccountData(account) =>
+ log.info(s"Retrieved account data for accountId = ${account.AccountId}")
+
+ this.account = account
+ admin = account.GM
+
+ Database.getConnection.connect.onComplete { // TODO remove that DB access.
+ case scala.util.Success(connection) =>
+ Database.query(connection.sendPreparedStatement(
+ "SELECT gm FROM accounts where id=?", Array(account.AccountId)
+ )).onComplete {
+ case scala.util.Success(queryResult) =>
+ queryResult match {
+ case row: ArrayRowData => // If we got a row from the database
+ log.info(s"Ready to load character list for ${account.Username}")
+ self ! ListAccountCharacters(Some(connection))
+ case _ => // If the account didn't exist in the database
+ log.error(s"Issue retrieving result set from database for account $account")
+ Thread.sleep(50)
+ if (connection.isConnected) connection.disconnect
+ sendResponse(DropSession(sessionId, "You should not exist !"))
+ }
+ case scala.util.Failure(e) =>
+ log.error("Is there a problem ? " + e.getMessage)
+ Thread.sleep(50)
+ if (connection.isConnected) connection.disconnect
+ }
+ case scala.util.Failure(e) =>
+ log.error("Failed connecting to database for account lookup " + e.getMessage)
+ }
case LoadedRemoteProjectile(projectile_guid, Some(projectile)) =>
if(projectile.profile.ExistsOnRemoteClients) {
//spawn projectile on other clients
@@ -3173,6 +3293,12 @@ class WorldSessionActor extends Actor
interstellarFerryTopLevelGUID = None
case _ => ;
}
+
+ if (loadConfZone && connectionState == 100) {
+ configZone(continent)
+ loadConfZone = false
+ }
+
if (noSpawnPointHere) {
RequestSanctuaryZoneSpawn(player, continent.Number)
}
@@ -3302,82 +3428,17 @@ class WorldSessionActor extends Actor
case ConnectToWorldRequestMessage(server, token, majorVersion, minorVersion, revision, buildDate, unk) =>
val clientVersion = s"Client Version: $majorVersion.$minorVersion.$revision, $buildDate"
log.info(s"New world login to $server with Token:$token. $clientVersion")
- //TODO begin temp player character auto-loading; remove later
- //this is all just temporary character creation used in the dev branch, making explicit values that allow for testing
- //the unique character identifier number for this testing character is based on the original test character,
- //whose identifier number was 41605314
- //all head features, faction, and sex also match that test character
- import net.psforever.objects.GlobalDefinitions._
- import net.psforever.types.CertificationType._
- val faction = PlanetSideEmpire.VS
- val avatar = new Avatar(41605313L+sessionId, s"TestCharacter$sessionId", faction, CharacterGender.Female, 41, CharacterVoice.Voice1)
- avatar.Certifications += StandardAssault
- avatar.Certifications += MediumAssault
- avatar.Certifications += StandardExoSuit
- avatar.Certifications += AgileExoSuit
- avatar.Certifications += ReinforcedExoSuit
- avatar.Certifications += ATV
- avatar.Certifications += Harasser
- avatar.Certifications += InfiltrationSuit
- avatar.Certifications += Sniping
- avatar.Certifications += AntiVehicular
- avatar.Certifications += HeavyAssault
- avatar.Certifications += SpecialAssault
- avatar.Certifications += EliteAssault
- avatar.Certifications += GroundSupport
- avatar.Certifications += GroundTransport
- avatar.Certifications += Flail
- avatar.Certifications += Switchblade
- avatar.Certifications += AssaultBuggy
- avatar.Certifications += ArmoredAssault1
- avatar.Certifications += ArmoredAssault2
- avatar.Certifications += AirCavalryScout
- avatar.Certifications += AirCavalryAssault
- avatar.Certifications += AirCavalryInterceptor
- avatar.Certifications += AirSupport
- avatar.Certifications += GalaxyGunship
- avatar.Certifications += Phantasm
- avatar.Certifications += UniMAX
- avatar.Certifications += Engineering
- avatar.Certifications += CombatEngineering
- avatar.Certifications += FortificationEngineering
- avatar.Certifications += AssaultEngineering
- avatar.Certifications += Hacking
- avatar.Certifications += AdvancedHacking
- avatar.Certifications += ElectronicsExpert
- avatar.Certifications += Medical
- avatar.Certifications += AdvancedMedical
- avatar.CEP = 6000001
- this.avatar = avatar
+ sendResponse(ChatMsg(ChatMessageType.CMT_CULLWATERMARK, false, "", "", None))
+
+ Thread.sleep(40)
- InitializeDeployableQuantities(avatar) //set deployables ui elements
- AwardBattleExperiencePoints(avatar, 1000000L)
- player = new Player(avatar)
- player.Position = Vector3(3561.0f, 2854.0f, 90.859375f) //home3, HART C
-// player.Position = Vector3(3940.3984f, 4343.625f, 266.45312f) //z6, Anguta
-// player.Position = Vector3(3571.2266f, 3278.0938f, 119.0f) //ce test
- player.Orientation = Vector3(0f, 0f, 90f)
- //player.Position = Vector3(4262.211f ,4067.0625f ,262.35938f) //z6, Akna.tower
- //player.Orientation = Vector3(0f, 0f, 132.1875f)
-// player.ExoSuit = ExoSuitType.MAX //TODO strange issue; divide number above by 10 when uncommenting
- player.Slot(0).Equipment = Tool(GlobalDefinitions.StandardPistol(player.Faction))
- player.Slot(2).Equipment = Tool(suppressor)
- player.Slot(4).Equipment = Tool(GlobalDefinitions.StandardMelee(player.Faction))
- player.Slot(6).Equipment = AmmoBox(bullet_9mm)
- player.Slot(9).Equipment = AmmoBox(bullet_9mm)
- player.Slot(12).Equipment = AmmoBox(bullet_9mm)
- player.Slot(33).Equipment = AmmoBox(bullet_9mm_AP)
- player.Slot(36).Equipment = AmmoBox(GlobalDefinitions.StandardPistolAmmo(player.Faction))
- player.Slot(39).Equipment = SimpleItem(remote_electronics_kit)
- player.Locker.Inventory += 0 -> SimpleItem(remote_electronics_kit)
- player.Inventory.Items.foreach { _.obj.Faction = faction }
- //TODO end temp player character auto-loading
- self ! ListAccountCharacters
import scala.concurrent.ExecutionContext.Implicits.global
clientKeepAlive.cancel
clientKeepAlive = context.system.scheduler.schedule(0 seconds, 500 milliseconds, self, PokeClient())
+ accountIntermediary ! RetrieveAccountData(token)
+
case msg @ MountVehicleCargoMsg(player_guid, vehicle_guid, cargo_vehicle_guid, unk4) =>
log.info(msg.toString)
(continent.GUID(vehicle_guid), continent.GUID(cargo_vehicle_guid)) match {
@@ -3410,19 +3471,183 @@ class WorldSessionActor extends Actor
case msg @ CharacterCreateRequestMessage(name, head, voice, gender, empire) =>
log.info("Handling " + msg)
- sendResponse(ActionResultMessage.Pass)
- self ! ListAccountCharacters
+
+ Database.getConnection.connect.onComplete {
+ case scala.util.Success(connection) =>
+ Database.query(connection.sendPreparedStatement(
+ "SELECT account_id FROM characters where name ILIKE ? AND deleted = false", Array(name)
+ )).onComplete {
+ case scala.util.Success(queryResult) =>
+ queryResult match {
+ case row: ArrayRowData => // If we got a row from the database
+ if (row(0).asInstanceOf[Int] == account.AccountId) { // create char
+// self ! CreateCharacter(Some(connection), name, head, voice, gender, empire)
+ sendResponse(ActionResultMessage.Fail(1))
+ Thread.sleep(50)
+ if (connection.isConnected) connection.disconnect
+ } else { // send "char already exist"
+ sendResponse(ActionResultMessage.Fail(1))
+ Thread.sleep(50)
+ if (connection.isConnected) connection.disconnect
+ }
+ case _ => // If the char name didn't exist in the database, create char
+ self ! CreateCharacter(Some(connection), name, head, voice, gender, empire)
+ }
+ case scala.util.Failure(e) =>
+ sendResponse(ActionResultMessage.Fail(4))
+ self ! ListAccountCharacters(Some(connection))
+ }
+ case scala.util.Failure(e) =>
+ log.error("Failed connecting to database for account lookup " + e.getMessage)
+ sendResponse(ActionResultMessage.Fail(5))
+ }
case msg @ CharacterRequestMessage(charId, action) =>
log.info("Handling " + msg)
action match {
case CharacterRequestAction.Delete =>
- sendResponse(ActionResultMessage.Fail(1))
+ Database.getConnection.connect.onComplete {
+ case scala.util.Success(connection) =>
+ Database.query(connection.sendPreparedStatement(
+ "UPDATE characters SET deleted = true where id=?", Array(charId)
+ )).onComplete {
+ case scala.util.Success(e) =>
+ log.info(s"Character id = $charId deleted")
+ sendResponse(ActionResultMessage.Pass)
+ self ! ListAccountCharacters(Some(connection))
+ case scala.util.Failure(e) =>
+ sendResponse(ActionResultMessage.Fail(6))
+ Thread.sleep(50)
+ if (connection.isConnected) connection.disconnect
+ }
+ case scala.util.Failure(e) =>
+ log.error("Failed connecting to database for account lookup " + e.getMessage)
+ }
+
case CharacterRequestAction.Select =>
- //TODO check if can spawn on last continent/location from player?
- //TODO if yes, get continent guid accessors
- //TODO if no, get sanctuary guid accessors and reset the player's expectations
- cluster ! InterstellarCluster.RequestClientInitialization()
+ import net.psforever.objects.GlobalDefinitions._
+ import net.psforever.types.CertificationType._
+
+ Database.getConnection.connect.onComplete {
+ case scala.util.Success(connection) =>
+ Database.query(connection.sendPreparedStatement(
+ "SELECT id, name, faction_id, gender_id, head_id, voice_id FROM characters where id=?", Array(charId)
+ )).onComplete {
+ case Success(queryResult) =>
+ queryResult match {
+ case row: ArrayRowData =>
+ val lName : String = row(1).asInstanceOf[String]
+ val lFaction : PlanetSideEmpire.Value = PlanetSideEmpire(row(2).asInstanceOf[Int])
+ val lGender : CharacterGender.Value = CharacterGender(row(3).asInstanceOf[Int])
+ val lHead : Int = row(4).asInstanceOf[Int]
+ val lVoice : CharacterVoice.Value = CharacterVoice(row(5).asInstanceOf[Int])
+ val avatar : Avatar = new Avatar(charId, lName, lFaction, lGender, lHead, lVoice)
+ avatar.Certifications += StandardAssault
+ avatar.Certifications += MediumAssault
+ avatar.Certifications += StandardExoSuit
+ avatar.Certifications += AgileExoSuit
+ avatar.Certifications += ReinforcedExoSuit
+ avatar.Certifications += ATV
+ // avatar.Certifications += Harasser
+ avatar.Certifications += InfiltrationSuit
+ avatar.Certifications += UniMAX
+ avatar.Certifications += Medical
+ avatar.Certifications += AdvancedMedical
+ avatar.Certifications += Engineering
+ avatar.Certifications += CombatEngineering
+ avatar.Certifications += FortificationEngineering
+ avatar.Certifications += AssaultEngineering
+ avatar.Certifications += Hacking
+ avatar.Certifications += AdvancedHacking
+ avatar.Certifications += ElectronicsExpert
+
+ avatar.Certifications += Sniping
+ avatar.Certifications += AntiVehicular
+ avatar.Certifications += HeavyAssault
+ avatar.Certifications += SpecialAssault
+ avatar.Certifications += EliteAssault
+ avatar.Certifications += GroundSupport
+ avatar.Certifications += GroundTransport
+ avatar.Certifications += Flail
+ avatar.Certifications += Switchblade
+ avatar.Certifications += AssaultBuggy
+ avatar.Certifications += ArmoredAssault1
+ avatar.Certifications += ArmoredAssault2
+ avatar.Certifications += AirCavalryScout
+ avatar.Certifications += AirCavalryAssault
+ avatar.Certifications += AirCavalryInterceptor
+ avatar.Certifications += AirSupport
+ avatar.Certifications += GalaxyGunship
+ avatar.Certifications += Phantasm
+ // player.Certifications += BattleFrameRobotics
+ // player.Certifications += BFRAntiInfantry
+ // player.Certifications += BFRAntiAircraft
+ this.avatar = avatar
+ InitializeDeployableQuantities(avatar) //set deployables ui elements
+ AwardBattleExperiencePoints(avatar, 20000000L)
+ avatar.CEP = 600000
+
+ player = new Player(avatar)
+
+ (0 until 4).foreach( index => {
+ if (player.Slot(index).Equipment.isDefined) player.Slot(index).Equipment = None
+ })
+ player.Inventory.Clear()
+
+ Database.getConnection.connect.onComplete {
+ case scala.util.Success(connection) =>
+ LoadDataBaseLoadouts(player, Some(connection))
+ case scala.util.Failure(e) =>
+ log.info(s"shit : ${e.getMessage}")
+ }
+ }
+ case _ =>
+ log.info("toto tata")
+ }
+ Thread.sleep(200)
+ Database.query(connection.sendPreparedStatement(
+ "UPDATE characters SET last_login = ? where id=?", Array(new java.sql.Timestamp(System.currentTimeMillis), charId)
+ ))
+ Thread.sleep(50)
+
+ var faction : String = "tr"
+ if (player.Faction == PlanetSideEmpire.NC) faction = "nc"
+ else if (player.Faction == PlanetSideEmpire.VS) faction = "vs"
+ whenUsedLastMAXName(2) = faction+"hev_antipersonnel"
+ whenUsedLastMAXName(3) = faction+"hev_antivehicular"
+ whenUsedLastMAXName(1) = faction+"hev_antiaircraft"
+
+ (0 until 4).foreach( index => {
+ if (player.Slot(index).Equipment.isDefined) player.Slot(index).Equipment = None
+ })
+ player.Inventory.Clear()
+ player.ExoSuit = ExoSuitType.Standard
+ player.Slot(0).Equipment = Tool(StandardPistol(player.Faction))
+ player.Slot(2).Equipment = Tool(suppressor)
+ player.Slot(4).Equipment = Tool(StandardMelee(player.Faction))
+ player.Slot(6).Equipment = AmmoBox(bullet_9mm)
+ player.Slot(9).Equipment = AmmoBox(bullet_9mm)
+ player.Slot(12).Equipment = AmmoBox(bullet_9mm)
+ player.Slot(33).Equipment = AmmoBox(bullet_9mm_AP)
+ player.Slot(36).Equipment = AmmoBox(StandardPistolAmmo(player.Faction))
+ player.Slot(39).Equipment = SimpleItem(remote_electronics_kit)
+ player.Inventory.Items.foreach { _.obj.Faction = player.Faction }
+ player.Locker.Inventory += 0 -> SimpleItem(remote_electronics_kit)
+ player.Position = Vector3(4000f ,4000f ,500f)
+ player.Orientation = Vector3(0f, 354.375f, 157.5f)
+ player.FirstLoad = true
+ // LivePlayerList.Add(sessionId, avatar)
+
+ //TODO check if can spawn on last continent/location from player?
+ //TODO if yes, get continent guid accessors
+ //TODO if no, get sanctuary guid accessors and reset the player's expectations
+ cluster ! InterstellarCluster.RequestClientInitialization()
+
+ if(connection.isConnected) connection.disconnect
+ case scala.util.Failure(e) =>
+ log.error("Failed connecting to database for account lookup " + e.getMessage)
+ }
+
case default =>
log.error("Unsupported " + default + " in " + msg)
}
@@ -3445,7 +3670,7 @@ class WorldSessionActor extends Actor
continent.VehicleEvents ! Service.Join(avatar.name)
continent.VehicleEvents ! Service.Join(continentId)
continent.VehicleEvents ! Service.Join(factionChannel)
- configZone(continent)
+ if(connectionState != 100) configZone(continent)
sendResponse(TimeOfDayMessage(1191182336))
//custom
sendResponse(ContinentalLockUpdateMessage(13, PlanetSideEmpire.VS)) // "The VS have captured the VS Sanctuary."
@@ -3929,7 +4154,7 @@ class WorldSessionActor extends Actor
//log.info("SetChatFilters: " + msg)
case msg @ ChatMsg(messagetype, has_wide_contents, recipient, contents, note_contents) =>
- var makeReply : Boolean = true
+ var makeReply : Boolean = false
var echoContents : String = contents
val trimContents = contents.trim
//TODO messy on/off strings may work
@@ -4022,8 +4247,12 @@ class WorldSessionActor extends Actor
if(player.isAlive && deadState != DeadState.Release) {
Suicide(player)
}
- }
- if(messagetype == ChatMessageType.CMT_DESTROY) {
+ } else if(messagetype == ChatMessageType.CMT_CULLWATERMARK) {
+ if(trimContents.contains("40 80")) connectionState = 100
+ else if(trimContents.contains("120 200")) connectionState = 25
+ else connectionState = 50
+ } else if(messagetype == ChatMessageType.CMT_DESTROY) {
+ makeReply = true
val guid = contents.toInt
continent.GUID(continent.Map.TerminalToSpawnPad.getOrElse(guid, guid)) match {
case Some(pad : VehicleSpawnPad) =>
@@ -4033,12 +4262,9 @@ class WorldSessionActor extends Actor
case _ =>
self ! PacketCoding.CreateGamePacket(0, RequestDestroyMessage(PlanetSideGUID(guid)))
}
- }
- if(messagetype == ChatMessageType.CMT_VOICE) {
+ } else if(messagetype == ChatMessageType.CMT_VOICE) {
sendResponse(ChatMsg(ChatMessageType.CMT_VOICE, false, player.Name, contents, None))
- }
-
- if(messagetype == ChatMessageType.CMT_QUIT) { // TODO: handle this appropriately
+ } else if(messagetype == ChatMessageType.CMT_QUIT) { // TODO: handle this appropriately
sendResponse(DropCryptoSession())
sendResponse(DropSession(sessionId, "user quit"))
}
@@ -5278,7 +5504,9 @@ class WorldSessionActor extends Actor
}) match {
case Some(owner : Player) => //InfantryLoadout
avatar.EquipmentLoadouts.SaveLoadout(owner, name, lineno)
+ SaveLoadoutToDB(owner, name, lineno)
import InfantryLoadout._
+// println(player_guid, line, name, DetermineSubtypeB(player.ExoSuit, DetermineSubtype(player)), player.ExoSuit, DetermineSubtype(player))
sendResponse(FavoritesMessage(list, player_guid, line, name, DetermineSubtypeB(player.ExoSuit, DetermineSubtype(player))))
case Some(owner : Vehicle) => //VehicleLoadout
avatar.EquipmentLoadouts.SaveLoadout(owner, name, lineno)
@@ -7643,6 +7871,7 @@ class WorldSessionActor extends Actor
})
// sendResponse(HackMessage(3, PlanetSideGUID(building.ModelId), PlanetSideGUID(0), 0, 3212836864L, HackState.HackCleared, 8))
+ Thread.sleep(connectionState)
})
}
@@ -9039,7 +9268,6 @@ class WorldSessionActor extends Actor
* @return all discovered `BoomTrigger` objects
*/
def RemoveBoomerTriggersFromInventory() : List[BoomerTrigger] = {
- val player_guid = player.GUID
val holstersWithIndex = player.Holsters().zipWithIndex
((player.Inventory.Items.collect({ case InventoryItem(obj : BoomerTrigger, index) => (obj, index) })) ++
(holstersWithIndex
@@ -9050,8 +9278,8 @@ class WorldSessionActor extends Actor
.map({ case ((obj, index)) =>
player.Slot(index).Equipment = None
sendResponse(ObjectDeleteMessage(obj.GUID, 0))
- if(player.VisibleSlots.contains(index)) {
- continent.AvatarEvents ! AvatarServiceMessage(continent.Id, AvatarAction.ObjectDelete(player_guid, obj.GUID))
+ if(player.VisibleSlots.contains(index) && player.HasGUID) {
+ continent.AvatarEvents ! AvatarServiceMessage(continent.Id, AvatarAction.ObjectDelete(player.GUID, obj.GUID))
}
obj
})
@@ -9824,6 +10052,632 @@ class WorldSessionActor extends Actor
}
}
+ def SaveLoadoutToDB(owner : Player, label : String, line : Int) = {
+ var megaList : String = ""
+ var localType : String = ""
+ var ammoInfo : String = ""
+ (0 until 5).foreach(index => {
+ if(owner.Slot(index).Equipment.isDefined) {
+ owner.Slot(index).Equipment.get match {
+ case test : Tool =>
+ localType = "Tool"
+ case test : AmmoBox =>
+ localType = "AmmoBox"
+ case test : ConstructionItem =>
+ localType = "ConstructionItem"
+ case test : BoomerTrigger =>
+ localType = "BoomerTrigger"
+ case test : SimpleItem =>
+ localType = "SimpleItem"
+ case test : Kit =>
+ localType = "Kit"
+ case _ =>
+ localType = ""
+ }
+ if(localType == "Tool") {
+ owner.Slot(index).Equipment.get.asInstanceOf[Tool].AmmoSlots.indices.foreach(index2 => {
+ if (owner.Slot(index).Equipment.get.asInstanceOf[Tool].AmmoSlots(index2).AmmoTypeIndex != 0) {
+ ammoInfo = ammoInfo+"_"+index2+"-"+owner.Slot(index).Equipment.get.asInstanceOf[Tool].AmmoSlots(index2).AmmoTypeIndex+"-"+owner.Slot(index).Equipment.get.asInstanceOf[Tool].AmmoSlots(index2).AmmoType.id
+ }
+ })
+ }
+ megaList = megaList + "/" + localType + "," + index + "," + owner.Slot(index).Equipment.get.Definition.ObjectId + "," + ammoInfo
+ ammoInfo = ""
+ }
+ })
+ owner.Inventory.Items.foreach(test => {
+ test.obj match {
+ case test : Tool =>
+ localType = "Tool"
+ case test : AmmoBox =>
+ localType = "AmmoBox"
+ case test : ConstructionItem =>
+ localType = "ConstructionItem"
+ case test : BoomerTrigger =>
+ localType = "BoomerTrigger"
+ case test : SimpleItem =>
+ localType = "SimpleItem"
+ case test : Kit =>
+ localType = "Kit"
+ case _ =>
+ localType = ""
+ }
+ if(localType == "Tool") {
+ owner.Slot(test.start).Equipment.get.asInstanceOf[Tool].AmmoSlots.indices.foreach(index2 => {
+ if (owner.Slot(test.start).Equipment.get.asInstanceOf[Tool].AmmoSlots(index2).AmmoTypeIndex != 0) {
+ ammoInfo = ammoInfo+"_"+index2+"-"+owner.Slot(test.start).Equipment.get.asInstanceOf[Tool].AmmoSlots(index2).AmmoTypeIndex+"-"+owner.Slot(test.start).Equipment.get.asInstanceOf[Tool].AmmoSlots(index2).AmmoType.id
+ }
+ })
+ }
+ megaList = megaList + "/" + localType + "," + test.start + "," + owner.Slot(test.start).Equipment.get.Definition.ObjectId + "," + ammoInfo
+ ammoInfo = ""
+ })
+
+ Database.getConnection.connect.onComplete {
+ case scala.util.Success(connection) =>
+ Database.query(connection.sendPreparedStatement(
+ "SELECT id, exosuit_id, name, items FROM loadouts where characters_id = ? AND loadout_number = ?", Array(owner.CharId, line)
+ )).onComplete {
+ case scala.util.Success(queryResult) =>
+ queryResult match {
+ case row: ArrayRowData => // Update
+ connection.sendPreparedStatement(
+ "UPDATE loadouts SET exosuit_id=?, name=?, items=? where id=?", Array(owner.ExoSuit.id, label, megaList.drop(1), row(0))
+ ) // Todo maybe add a .onComplete ?
+ Thread.sleep(50)
+ if (connection.isConnected) connection.disconnect
+ case _ => // Save
+ connection.sendPreparedStatement(
+ "INSERT INTO loadouts (characters_id, loadout_number, exosuit_id, name, items) VALUES(?,?,?,?,?) RETURNING id",
+ Array(owner.CharId, line, owner.ExoSuit.id, label, megaList.drop(1))
+ ) // Todo maybe add a .onComplete ?
+ Thread.sleep(50)
+ if (connection.isConnected) connection.disconnect
+ }
+ case scala.util.Failure(e) =>
+ log.error("Failed to execute the query " + e.getMessage)
+ }
+ case scala.util.Failure(e) =>
+ log.error("Failed connecting to database " + e.getMessage)
+ }
+ }
+
+ def LoadDataBaseLoadouts(owner : Player, connection: Option[Connection]) = {
+ connection.get.sendPreparedStatement(
+ "SELECT id, loadout_number, exosuit_id, name, items FROM loadouts where characters_id = ?", Array(owner.CharId)
+ ).onComplete {
+ case Success(queryResult) =>
+ queryResult match {
+ case result: QueryResult =>
+ if (result.rows.nonEmpty) {
+ result.rows foreach{ row =>
+ row.zipWithIndex.foreach{ case (value,i) =>
+ val lLoadoutNumber : Int = value(1).asInstanceOf[Int]
+ val lExosuitId : Int = value(2).asInstanceOf[Int]
+ val lName : String = value(3).asInstanceOf[String]
+ val lItems : String = value(4).asInstanceOf[String]
+
+ owner.ExoSuit = ExoSuitType(lExosuitId)
+
+ val args = lItems.split("/")
+ args.indices.foreach(i => {
+ val args2 = args(i).split(",")
+ val lType = args2(0)
+ val lIndex : Int = args2(1).toInt
+ val lObjectId : Int = args2(2).toInt
+
+ lType match {
+ case "Tool" =>
+ owner.Slot(lIndex).Equipment = Tool(GetToolDefFromObjectID(lObjectId).asInstanceOf[ToolDefinition])
+ case "AmmoBox" =>
+ owner.Slot(lIndex).Equipment = AmmoBox(GetToolDefFromObjectID(lObjectId).asInstanceOf[AmmoBoxDefinition])
+ case "ConstructionItem" =>
+ owner.Slot(lIndex).Equipment = ConstructionItem(GetToolDefFromObjectID(lObjectId).asInstanceOf[ConstructionItemDefinition])
+ case "BoomerTrigger" =>
+ log.error("Found a BoomerTrigger in a loadout ?!")
+ case "SimpleItem" =>
+ owner.Slot(lIndex).Equipment = SimpleItem(GetToolDefFromObjectID(lObjectId).asInstanceOf[SimpleItemDefinition])
+ case "Kit" =>
+ owner.Slot(lIndex).Equipment = Kit(GetToolDefFromObjectID(lObjectId).asInstanceOf[KitDefinition])
+ case _ =>
+ log.error("What's that item in the loadout ?!")
+ }
+ if (args2.length == 4) {
+ val args3 = args2(3).split("_")
+ (1 until args3.length).foreach(j => {
+ val args4 = args3(j).split("-")
+ val lAmmoSlots = args4(0).toInt
+ val lAmmoTypeIndex = args4(1).toInt
+ val lAmmoBoxDefinition = args4(2).toInt
+ owner.Slot(lIndex).Equipment.get.asInstanceOf[Tool].AmmoSlots(lAmmoSlots).AmmoTypeIndex = lAmmoTypeIndex
+ owner.Slot(lIndex).Equipment.get.asInstanceOf[Tool].AmmoSlots(lAmmoSlots).Box = AmmoBox(AmmoBoxDefinition(lAmmoBoxDefinition))
+ })
+ }
+ })
+ avatar.EquipmentLoadouts.SaveLoadout(owner, lName, lLoadoutNumber)
+ (0 until 4).foreach( index => {
+ if (owner.Slot(index).Equipment.isDefined) owner.Slot(index).Equipment = None
+ })
+ owner.Inventory.Clear()
+ }
+ // something to do at end of loading ?
+ }
+ }
+ Thread.sleep(50)
+ if (connection.get.isConnected) connection.get.disconnect
+ case _ =>
+ log.error(s"No saved loadout(s) for character ID : ${owner.CharId}")
+ }
+ case scala.util.Failure(e) =>
+ log.error("Failed connecting to database " + e.getMessage)
+ }
+ }
+
+ def LoadDefaultLoadouts() = {
+ // 1
+ player.ExoSuit = ExoSuitType.Agile
+ player.Slot(0).Equipment = Tool(frag_grenade)
+ player.Slot(1).Equipment = Tool(bank)
+ player.Slot(2).Equipment = Tool(HeavyRifle(player.Faction))
+ player.Slot(4).Equipment = Tool(StandardMelee(player.Faction))
+ player.Slot(6).Equipment = Tool(medicalapplicator)
+ player.Slot(9).Equipment = AmmoBox(HeavyRifleAmmo(player.Faction))
+ player.Slot(12).Equipment = AmmoBox(HeavyRifleAmmo(player.Faction))
+ player.Slot(33).Equipment = Tool(phoenix)
+ player.Slot(60).Equipment = SimpleItem(remote_electronics_kit)
+ player.Slot(72).Equipment = Tool(jammer_grenade)
+ player.Slot(74).Equipment = Kit(medkit)
+ avatar.EquipmentLoadouts.SaveLoadout(player, "Agile HA/Deci", 0)
+ (0 until 4).foreach( index => {
+ if (player.Slot(index).Equipment.isDefined) player.Slot(index).Equipment = None
+ })
+ player.Inventory.Clear()
+
+ // 2
+ player.ExoSuit = ExoSuitType.Agile
+ player.Slot(0).Equipment = Tool(frag_grenade)
+ player.Slot(1).Equipment = Tool(frag_grenade)
+ player.Slot(2).Equipment = Tool(HeavyRifle(player.Faction))
+ player.Slot(4).Equipment = Tool(StandardMelee(player.Faction))
+ player.Slot(6).Equipment = AmmoBox(HeavyRifleAmmo(player.Faction))
+ player.Slot(9).Equipment = AmmoBox(HeavyRifleAmmo(player.Faction))
+ player.Slot(12).Equipment = SimpleItem(remote_electronics_kit)
+ player.Slot(33).Equipment = Tool(medicalapplicator)
+ player.Slot(36).Equipment = Tool(frag_grenade)
+ player.Slot(38).Equipment = Kit(medkit)
+ player.Slot(54).Equipment = Tool(frag_grenade)
+ player.Slot(56).Equipment = Kit(medkit)
+ player.Slot(60).Equipment = Tool(bank)
+ player.Slot(72).Equipment = Tool(jammer_grenade)
+ player.Slot(74).Equipment = Kit(medkit)
+ avatar.EquipmentLoadouts.SaveLoadout(player, "Agile HA", 1)
+ (0 until 4).foreach( index => {
+ if (player.Slot(index).Equipment.isDefined) player.Slot(index).Equipment = None
+ })
+ player.Inventory.Clear()
+
+ // 3
+ player.ExoSuit = ExoSuitType.Reinforced
+ player.Slot(0).Equipment = Tool(medicalapplicator)
+ player.Slot(1).Equipment = SimpleItem(remote_electronics_kit)
+ player.Slot(2).Equipment = Tool(HeavyRifle(player.Faction))
+ player.Slot(3).Equipment = Tool(phoenix)
+ player.Slot(4).Equipment = Tool(StandardMelee(player.Faction))
+ player.Slot(6).Equipment = AmmoBox(HeavyRifleAmmo(player.Faction))
+ player.Slot(9).Equipment = AmmoBox(HeavyRifleAmmo(player.Faction))
+ player.Slot(12).Equipment = Kit(medkit)
+ player.Slot(16).Equipment = Tool(frag_grenade)
+ player.Slot(36).Equipment = Kit(medkit)
+ player.Slot(40).Equipment = Tool(frag_grenade)
+ player.Slot(42).Equipment = AmmoBox(HeavyRifleAmmo(player.Faction))
+ player.Slot(45).Equipment = AmmoBox(HeavyRifleAPAmmo(player.Faction))
+ player.Slot(60).Equipment = Kit(medkit)
+ player.Slot(64).Equipment = Tool(jammer_grenade)
+ player.Slot(78).Equipment = Tool(phoenix)
+ player.Slot(87).Equipment = Tool(bank)
+ avatar.EquipmentLoadouts.SaveLoadout(player, "Rexo HA/Deci", 2)
+ (0 until 4).foreach( index => {
+ if (player.Slot(index).Equipment.isDefined) player.Slot(index).Equipment = None
+ })
+ player.Inventory.Clear()
+
+ // 4
+ player.ExoSuit = ExoSuitType.Reinforced
+ player.Slot(0).Equipment = Tool(medicalapplicator)
+ player.Slot(1).Equipment = SimpleItem(remote_electronics_kit)
+ player.Slot(2).Equipment = Tool(MediumRifle(player.Faction))
+ player.Slot(3).Equipment = Tool(AntiVehicularLauncher(player.Faction))
+ player.Slot(4).Equipment = Tool(StandardMelee(player.Faction))
+ player.Slot(6).Equipment = AmmoBox(MediumRifleAmmo(player.Faction))
+ player.Slot(9).Equipment = AmmoBox(MediumRifleAmmo(player.Faction))
+ player.Slot(12).Equipment = AmmoBox(MediumRifleAmmo(player.Faction))
+ player.Slot(15).Equipment = Tool(bank)
+ player.Slot(42).Equipment = Tool(frag_grenade)
+ player.Slot(44).Equipment = Tool(jammer_grenade)
+ player.Slot(46).Equipment = Kit(medkit)
+ player.Slot(50).Equipment = Kit(medkit)
+ player.Slot(66).Equipment = AmmoBox(AntiVehicularAmmo(player.Faction))
+ player.Slot(70).Equipment = AmmoBox(AntiVehicularAmmo(player.Faction))
+ player.Slot(74).Equipment = AmmoBox(AntiVehicularAmmo(player.Faction))
+ avatar.EquipmentLoadouts.SaveLoadout(player, "Rexo MA/AV", 3)
+ (0 until 4).foreach( index => {
+ if (player.Slot(index).Equipment.isDefined) player.Slot(index).Equipment = None
+ })
+ player.Inventory.Clear()
+
+ // 5
+ player.ExoSuit = ExoSuitType.Reinforced
+ player.Slot(0).Equipment = Tool(medicalapplicator)
+ player.Slot(1).Equipment = SimpleItem(remote_electronics_kit)
+ player.Slot(2).Equipment = Tool(HeavyRifle(player.Faction))
+ player.Slot(3).Equipment = Tool(thumper)
+ player.Slot(4).Equipment = Tool(StandardMelee(player.Faction))
+ player.Slot(6).Equipment = AmmoBox(HeavyRifleAmmo(player.Faction))
+ player.Slot(9).Equipment = AmmoBox(HeavyRifleAmmo(player.Faction))
+ player.Slot(12).Equipment = Kit(medkit)
+ player.Slot(16).Equipment = Tool(frag_grenade)
+ player.Slot(36).Equipment = Kit(medkit)
+ player.Slot(40).Equipment = Tool(frag_grenade)
+ player.Slot(42).Equipment = AmmoBox(HeavyRifleAmmo(player.Faction))
+ player.Slot(45).Equipment = AmmoBox(HeavyRifleAPAmmo(player.Faction))
+ player.Slot(60).Equipment = Kit(medkit)
+ player.Slot(64).Equipment = Tool(jammer_grenade)
+ player.Slot(78).Equipment = Tool(bank)
+ player.Slot(81).Equipment = AmmoBox(frag_cartridge)
+ player.Slot(84).Equipment = AmmoBox(frag_cartridge)
+ player.Slot(87).Equipment = AmmoBox(frag_cartridge)
+ avatar.EquipmentLoadouts.SaveLoadout(player, "Rexo HA/Thumper", 4)
+ (0 until 4).foreach( index => {
+ if (player.Slot(index).Equipment.isDefined) player.Slot(index).Equipment = None
+ })
+ player.Inventory.Clear()
+
+ // 6
+ player.ExoSuit = ExoSuitType.Reinforced
+ player.Slot(0).Equipment = Tool(medicalapplicator)
+ player.Slot(1).Equipment = SimpleItem(remote_electronics_kit)
+ player.Slot(2).Equipment = Tool(HeavyRifle(player.Faction))
+ player.Slot(3).Equipment = Tool(rocklet)
+ player.Slot(4).Equipment = Tool(StandardMelee(player.Faction))
+ player.Slot(6).Equipment = AmmoBox(HeavyRifleAmmo(player.Faction))
+ player.Slot(9).Equipment = AmmoBox(HeavyRifleAmmo(player.Faction))
+ player.Slot(12).Equipment = Kit(medkit)
+ player.Slot(16).Equipment = Tool(frag_grenade)
+ player.Slot(36).Equipment = Kit(medkit)
+ player.Slot(40).Equipment = Tool(frag_grenade)
+ player.Slot(42).Equipment = AmmoBox(HeavyRifleAmmo(player.Faction))
+ player.Slot(45).Equipment = AmmoBox(HeavyRifleAPAmmo(player.Faction))
+ player.Slot(60).Equipment = Kit(medkit)
+ player.Slot(64).Equipment = Tool(jammer_grenade)
+ player.Slot(78).Equipment = Tool(bank)
+ player.Slot(81).Equipment = AmmoBox(rocket)
+ player.Slot(84).Equipment = AmmoBox(rocket)
+ player.Slot(87).Equipment = AmmoBox(frag_cartridge)
+ avatar.EquipmentLoadouts.SaveLoadout(player, "Rexo HA/Rocklet", 5)
+ (0 until 4).foreach( index => {
+ if (player.Slot(index).Equipment.isDefined) player.Slot(index).Equipment = None
+ })
+ player.Inventory.Clear()
+
+ // 7
+ player.ExoSuit = ExoSuitType.Reinforced
+ player.Slot(0).Equipment = Tool(medicalapplicator)
+ player.Slot(1).Equipment = SimpleItem(remote_electronics_kit)
+ player.Slot(2).Equipment = Tool(MediumRifle(player.Faction))
+ player.Slot(3).Equipment = Tool(bolt_driver)
+ player.Slot(4).Equipment = Tool(StandardMelee(player.Faction))
+ player.Slot(6).Equipment = AmmoBox(MediumRifleAmmo(player.Faction))
+ player.Slot(9).Equipment = AmmoBox(MediumRifleAmmo(player.Faction))
+ player.Slot(12).Equipment = Kit(medkit)
+ player.Slot(16).Equipment = Tool(frag_grenade)
+ player.Slot(36).Equipment = Kit(medkit)
+ player.Slot(40).Equipment = Tool(frag_grenade)
+ player.Slot(42).Equipment = AmmoBox(MediumRifleAmmo(player.Faction))
+ player.Slot(45).Equipment = AmmoBox(MediumRifleAPAmmo(player.Faction))
+ player.Slot(60).Equipment = Kit(medkit)
+ player.Slot(64).Equipment = Tool(jammer_grenade)
+ player.Slot(78).Equipment = Tool(bank)
+ player.Slot(81).Equipment = AmmoBox(bolt)
+ player.Slot(84).Equipment = AmmoBox(bolt)
+ player.Slot(87).Equipment = AmmoBox(bolt)
+ avatar.EquipmentLoadouts.SaveLoadout(player, "Rexo MA/Sniper", 6)
+ (0 until 4).foreach( index => {
+ if (player.Slot(index).Equipment.isDefined) player.Slot(index).Equipment = None
+ })
+ player.Inventory.Clear()
+
+ // 8
+ player.ExoSuit = ExoSuitType.Reinforced
+ player.Slot(0).Equipment = Tool(medicalapplicator)
+ player.Slot(1).Equipment = SimpleItem(remote_electronics_kit)
+ player.Slot(2).Equipment = Tool(flechette)
+ player.Slot(3).Equipment = Tool(phoenix)
+ player.Slot(4).Equipment = Tool(StandardMelee(player.Faction))
+ player.Slot(6).Equipment = AmmoBox(shotgun_shell)
+ player.Slot(9).Equipment = AmmoBox(shotgun_shell)
+ player.Slot(12).Equipment = Kit(medkit)
+ player.Slot(16).Equipment = Tool(frag_grenade)
+ player.Slot(36).Equipment = Kit(medkit)
+ player.Slot(40).Equipment = Tool(frag_grenade)
+ player.Slot(42).Equipment = AmmoBox(shotgun_shell)
+ player.Slot(45).Equipment = AmmoBox(shotgun_shell_AP)
+ player.Slot(60).Equipment = Kit(medkit)
+ player.Slot(64).Equipment = Tool(jammer_grenade)
+ player.Slot(78).Equipment = Tool(phoenix)
+ player.Slot(87).Equipment = Tool(bank)
+ avatar.EquipmentLoadouts.SaveLoadout(player, "Rexo Sweeper/Deci", 7)
+ (0 until 4).foreach( index => {
+ if (player.Slot(index).Equipment.isDefined) player.Slot(index).Equipment = None
+ })
+ player.Inventory.Clear()
+
+ // 9
+ player.ExoSuit = ExoSuitType.MAX
+ player.Slot(0).Equipment = Tool(AI_MAX(player.Faction))
+ player.Slot(6).Equipment = AmmoBox(AI_MAXAmmo(player.Faction))
+ player.Slot(10).Equipment = AmmoBox(AI_MAXAmmo(player.Faction))
+ player.Slot(14).Equipment = AmmoBox(AI_MAXAmmo(player.Faction))
+ player.Slot(18).Equipment = AmmoBox(AI_MAXAmmo(player.Faction))
+ player.Slot(70).Equipment = AmmoBox(AI_MAXAmmo(player.Faction))
+ player.Slot(74).Equipment = AmmoBox(AI_MAXAmmo(player.Faction))
+ player.Slot(78).Equipment = Kit(medkit)
+ player.Slot(98).Equipment = AmmoBox(health_canister)
+ player.Slot(100).Equipment = AmmoBox(armor_canister)
+ player.Slot(110).Equipment = Kit(medkit)
+ player.Slot(134).Equipment = Kit(medkit)
+ player.Slot(138).Equipment = Kit(medkit)
+ player.Slot(142).Equipment = Kit(medkit)
+ player.Slot(146).Equipment = Kit(medkit)
+ player.Slot(166).Equipment = Kit(medkit)
+ player.Slot(170).Equipment = Kit(medkit)
+ player.Slot(174).Equipment = Kit(medkit)
+ player.Slot(178).Equipment = Kit(medkit)
+ avatar.EquipmentLoadouts.SaveLoadout(player, "AI MAX", 8)
+ (0 until 4).foreach( index => {
+ if (player.Slot(index).Equipment.isDefined) player.Slot(index).Equipment = None
+ })
+ player.Inventory.Clear()
+
+ // 10
+ player.ExoSuit = ExoSuitType.MAX
+ player.Slot(0).Equipment = Tool(AV_MAX(player.Faction))
+ player.Slot(6).Equipment = AmmoBox(AV_MAXAmmo(player.Faction))
+ player.Slot(10).Equipment = AmmoBox(AV_MAXAmmo(player.Faction))
+ player.Slot(14).Equipment = AmmoBox(AV_MAXAmmo(player.Faction))
+ player.Slot(18).Equipment = AmmoBox(AV_MAXAmmo(player.Faction))
+ player.Slot(70).Equipment = AmmoBox(AV_MAXAmmo(player.Faction))
+ player.Slot(74).Equipment = AmmoBox(AV_MAXAmmo(player.Faction))
+ player.Slot(78).Equipment = Kit(medkit)
+ player.Slot(98).Equipment = AmmoBox(health_canister)
+ player.Slot(100).Equipment = AmmoBox(armor_canister)
+ player.Slot(110).Equipment = Kit(medkit)
+ player.Slot(134).Equipment = Kit(medkit)
+ player.Slot(138).Equipment = Kit(medkit)
+ player.Slot(142).Equipment = Kit(medkit)
+ player.Slot(146).Equipment = Kit(medkit)
+ player.Slot(166).Equipment = Kit(medkit)
+ player.Slot(170).Equipment = Kit(medkit)
+ player.Slot(174).Equipment = Kit(medkit)
+ player.Slot(178).Equipment = Kit(medkit)
+ avatar.EquipmentLoadouts.SaveLoadout(player, "AV MAX", 9)
+ (0 until 4).foreach( index => {
+ if (player.Slot(index).Equipment.isDefined) player.Slot(index).Equipment = None
+ })
+ player.Inventory.Clear()
+ }
+
+ def GetToolDefFromObjectID(objectID : Int) : Any = {
+ objectID match {
+ //ammunition
+ case 0 => bullet_105mm
+ case 3 => bullet_12mm
+ case 6 => bullet_150mm
+ case 9 => bullet_15mm
+ case 16 => bullet_20mm
+ case 19 => bullet_25mm
+ case 21 => bullet_35mm
+ case 25 => bullet_75mm
+ case 28 => bullet_9mm
+ case 29 => bullet_9mm_AP
+ case 50 => ancient_ammo_combo
+ case 51 => ancient_ammo_vehicle
+ case 54 => anniversary_ammo
+ case 86 => aphelion_immolation_cannon_ammo
+ case 89 => aphelion_laser_ammo
+ case 97 => aphelion_plasma_rocket_ammo
+ case 101 => aphelion_ppa_ammo
+ case 106 => aphelion_starfire_ammo
+ case 111 => armor_canister
+ case 145 => bolt
+ case 154 => burster_ammo
+ case 180 => colossus_100mm_cannon_ammo
+ case 186 => colossus_burster_ammo
+ case 191 => colossus_chaingun_ammo
+ case 195 => colossus_cluster_bomb_ammo
+ case 205 => colossus_tank_cannon_ammo
+ case 209 => comet_ammo
+ case 265 => dualcycler_ammo
+ case 272 => energy_cell
+ case 275 => energy_gun_ammo
+ case 285 => falcon_ammo
+ case 287 => firebird_missile
+ case 300 => flamethrower_ammo
+ case 307 => flux_cannon_thresher_battery
+ case 310 => fluxpod_ammo
+ case 327 => frag_cartridge
+ case 331 => frag_grenade_ammo
+ case 347 => gauss_cannon_ammo
+ case 389 => health_canister
+ case 391 => heavy_grenade_mortar
+ case 393 => heavy_rail_beam_battery
+ case 399 => hellfire_ammo
+ case 403 => hunter_seeker_missile
+ case 413 => jammer_cartridge
+ case 417 => jammer_grenade_ammo
+ case 426 => lancer_cartridge
+ case 434 => liberator_bomb
+ case 463 => maelstrom_ammo
+ case 540 => melee_ammo
+ case 600 => oicw_ammo
+ case 630 => pellet_gun_ammo
+ case 637 => peregrine_dual_machine_gun_ammo
+ case 645 => peregrine_mechhammer_ammo
+ case 653 => peregrine_particle_cannon_ammo
+ case 656 => peregrine_rocket_pod_ammo
+ case 659 => peregrine_sparrow_ammo
+ case 664 => phalanx_ammo
+ case 674 => phoenix_missile
+ case 677 => plasma_cartridge
+ case 681 => plasma_grenade_ammo
+ case 693 => pounder_ammo
+ case 704 => pulse_battery
+ case 712 => quasar_ammo
+ case 722 => reaver_rocket
+ case 734 => rocket
+ case 745 => scattercannon_ammo
+ case 755 => shotgun_shell
+ case 756 => shotgun_shell_AP
+ case 762 => six_shooter_ammo
+ case 786 => skyguard_flak_cannon_ammo
+ case 791 => sparrow_ammo
+ case 820 => spitfire_aa_ammo
+ case 823 => spitfire_ammo
+ case 830 => starfire_ammo
+ case 839 => striker_missile_ammo
+ case 877 => trek_ammo
+ case 922 => upgrade_canister
+ case 998 => wasp_gun_ammo
+ case 1000 => wasp_rocket_ammo
+ case 1004 => winchester_ammo
+ //weapons
+ case 14 => cannon_dropship_20mm
+ case 40 => advanced_missile_launcher_t
+ case 55 => anniversary_gun
+ case 56 => anniversary_guna
+ case 57 => anniversary_gunb
+ case 63 => apc_ballgun_l
+ case 64 => apc_ballgun_r
+ case 69 => apc_weapon_systema
+ case 70 => apc_weapon_systemb
+ case 72 => apc_weapon_systemc_nc
+ case 73 => apc_weapon_systemc_tr
+ case 74 => apc_weapon_systemc_vs
+ case 76 => apc_weapon_systemd_nc
+ case 77 => apc_weapon_systemd_tr
+ case 78 => apc_weapon_systemd_vs
+ case 119 => aurora_weapon_systema
+ case 120 => aurora_weapon_systemb
+ case 136 => battlewagon_weapon_systema
+ case 137 => battlewagon_weapon_systemb
+ case 138 => battlewagon_weapon_systemc
+ case 139 => battlewagon_weapon_systemd
+ case 140 => beamer
+ case 146 => bolt_driver
+ case 175 => chainblade
+ case 177 => chaingun_p
+ case 233 => cycler
+ case 262 => dropship_rear_turret
+ case 274 => energy_gun
+ case 276 => energy_gun_nc
+ case 278 => energy_gun_tr
+ case 280 => energy_gun_vs
+ case 298 => flail_weapon
+ case 299 => flamethrower
+ case 304 => flechette
+ case 306 => flux_cannon_thresher
+ case 324 => forceblade
+ case 336 => fury_weapon_systema
+ case 339 => galaxy_gunship_cannon
+ case 340 => galaxy_gunship_gun
+ case 342 => galaxy_gunship_tailgun
+ case 345 => gauss
+ case 371 => grenade_launcher_marauder
+ case 394 => heavy_rail_beam_magrider
+ case 396 => heavy_sniper
+ case 406 => hunterseeker
+ case 407 => ilc9
+ case 411 => isp
+ case 421 => katana
+ case 425 => lancer
+ case 429 => lasher
+ case 433 => liberator_25mm_cannon
+ case 435 => liberator_bomb_bay
+ case 440 => liberator_weapon_system
+ case 445 => lightgunship_weapon_system
+ case 448 => lightning_weapon_system
+ case 462 => maelstrom
+ case 468 => magcutter
+ case 534 => mediumtransport_weapon_systemA
+ case 535 => mediumtransport_weapon_systemB
+ case 556 => mini_chaingun
+ case 587 => nchev_falcon
+ case 588 => nchev_scattercannon
+ case 589 => nchev_sparrow
+ case 599 => oicw
+ case 628 => particle_beam_magrider
+ case 629 => pellet_gun
+ case 666 => phalanx_avcombo
+ case 668 => phalanx_flakcombo
+ case 670 => phalanx_sgl_hevgatcan
+ case 673 => phoenix
+ case 699 => prowler_weapon_systemA
+ case 700 => prowler_weapon_systemB
+ case 701 => pulsar
+ case 706 => punisher
+ case 709 => quadassault_weapon_system
+ case 714 => r_shotgun
+ case 716 => radiator
+ case 730 => repeater
+ case 737 => rocklet
+ case 740 => rotarychaingun_mosquito
+ case 747 => scythe
+ case 761 => six_shooter
+ case 788 => skyguard_weapon_system
+ case 817 => spiker
+ case 822 => spitfire_aa_weapon
+ case 827 => spitfire_weapon
+ case 838 => striker
+ case 845 => suppressor
+ case 864 => thumper
+ case 866 => thunderer_weapon_systema
+ case 867 => thunderer_weapon_systemb
+ case 888 => trhev_burster
+ case 889 => trhev_dualcycler
+ case 890 => trhev_pounder
+ case 927 => vanguard_weapon_system
+ case 968 => vshev_comet
+ case 969 => vshev_quasar
+ case 970 => vshev_starfire
+ case 987 => vulture_bomb_bay
+ case 990 => vulture_nose_weapon_system
+ case 992 => vulture_tail_cannon
+ case 1002 => wasp_weapon_system
+ case 1003 => winchester
+ case 267 => dynomite
+ case 330 => frag_grenade
+ case 416 => jammer_grenade
+ case 680 => plasma_grenade
+ //medkits
+ case 536 => medkit
+ case 842 => super_armorkit
+ case 843 => super_medkit
+ case 844 => super_staminakit
+ //tools
+ case 728 => remote_electronics_kit
+ case 876 => trek
+ case 531 => medicalapplicator
+ case 132 => bank
+ case 577 => nano_dispenser
+ case 213 => command_detonater
+ case 297 => flail_targeting_laser
+ //deployables
+ case 32 => ace
+ case 39 => advanced_ace
+ case 148 => boomer
+ case 149 => boomer_trigger
+ case _ => frag_grenade
+ }
+ }
/**
* Make this client display the deployment map, and all its available destination spawn points.
* @see `AvatarDeadStateMessage`
@@ -10309,7 +11163,8 @@ object WorldSessionActor {
private final case class NewPlayerLoaded(tplayer : Player)
private final case class PlayerLoaded(tplayer : Player)
private final case class PlayerFailedToLoad(tplayer : Player)
- private final case class ListAccountCharacters()
+ private final case class CreateCharacter(connection: Option[Connection], name : String, head : Int, voice : CharacterVoice.Value, gender : CharacterGender.Value, empire : PlanetSideEmpire.Value)
+ private final case class ListAccountCharacters(connection: Option[Connection])
private final case class SetCurrentAvatar(tplayer : Player)
private final case class VehicleLoaded(vehicle : Vehicle)
private final case class UnregisterCorpseOnVehicleDisembark(corpse : Player)
diff --git a/pslogin/src/test/resources/testconfig.ini b/pslogin/src/test/resources/testconfig.ini
index 64c98ee4..7f25be14 100644
--- a/pslogin/src/test/resources/testconfig.ini
+++ b/pslogin/src/test/resources/testconfig.ini
@@ -8,6 +8,7 @@ time2 = 100 milliseconds
float = 0.1
bool_true = yes
bool_false = no
+enum_dog = Dog
# missing
[bad]
@@ -17,3 +18,4 @@ bad_float = A
bad_bool = dunno
bad_int_range = -1
bad_int_range2 = 3
+bad_enum = Tree
diff --git a/pslogin/src/test/scala/ConfigTest.scala b/pslogin/src/test/scala/ConfigTest.scala
index 6bbea433..1f5a422b 100644
--- a/pslogin/src/test/scala/ConfigTest.scala
+++ b/pslogin/src/test/scala/ConfigTest.scala
@@ -6,6 +6,8 @@ import net.psforever.config._
import scala.concurrent.duration._
class ConfigTest extends Specification {
+ sequential
+
val testConfig = getClass.getResource("/testconfig.ini").getPath
"WorldConfig" should {
@@ -44,6 +46,7 @@ class ConfigTest extends Specification {
TestConfig.Get[Float]("default.float") mustEqual 0.1f
TestConfig.Get[Boolean]("default.bool_true") mustEqual true
TestConfig.Get[Boolean]("default.bool_false") mustEqual false
+ TestConfig.Get[TestEnum.Value]("default.enum_dog") mustEqual TestEnum.Dog
TestConfig.Get[Int]("default.missing") mustEqual 1337
}
@@ -70,7 +73,8 @@ class ConfigTest extends Specification {
ValidationError("bad.bad_float: value format error (expected: Float)"),
ValidationError("bad.bad_bool: value format error (expected: Bool)"),
ValidationError("bad.bad_int_range: error.min", 0),
- ValidationError("bad.bad_int_range2: error.max", 2)
+ ValidationError("bad.bad_int_range2: error.max", 2),
+ ValidationError("bad.bad_enum: value format error (expected: Animal, Dog, Cat)")
)
error.errors mustEqual check_errors
@@ -78,9 +82,11 @@ class ConfigTest extends Specification {
}
}
-object TestConfig extends ConfigParser {
- protected var config_map : Map[String, Any] = Map()
+object TestEnum extends Enumeration {
+ val Animal, Dog, Cat = Value
+}
+object TestConfig extends ConfigParser {
protected val config_template = Seq(
ConfigSection("default",
ConfigEntryString("string", ""),
@@ -91,14 +97,13 @@ object TestConfig extends ConfigParser {
ConfigEntryFloat("float", 0.0f),
ConfigEntryBool("bool_true", false),
ConfigEntryBool("bool_false", true),
- ConfigEntryInt("missing", 1337)
+ ConfigEntryInt("missing", 1337),
+ ConfigEntryEnum[TestEnum.type]("enum_dog", TestEnum.Dog)
)
)
}
object TestBadConfig extends ConfigParser {
- protected var config_map : Map[String, Any] = Map()
-
protected val config_template = Seq(
ConfigSection("bad",
ConfigEntryInt("bad_int", 0),
@@ -106,7 +111,8 @@ object TestBadConfig extends ConfigParser {
ConfigEntryFloat("bad_float", 0.0f),
ConfigEntryBool("bad_bool", false),
ConfigEntryInt("bad_int_range", 0, Constraints.min(0)),
- ConfigEntryInt("bad_int_range2", 0, Constraints.min(0), Constraints.max(2))
+ ConfigEntryInt("bad_int_range2", 0, Constraints.min(0), Constraints.max(2)),
+ ConfigEntryEnum[TestEnum.type]("bad_enum", TestEnum.Animal)
)
)
}
diff --git a/schema.sql b/schema.sql
new file mode 100644
index 00000000..827c5b56
--- /dev/null
+++ b/schema.sql
@@ -0,0 +1,69 @@
+CREATE TABLE IF NOT EXISTS "accounts" (
+ "id" SERIAL PRIMARY KEY NOT NULL,
+ "username" VARCHAR(64) NOT NULL UNIQUE,
+ "passhash" VARCHAR(64) NOT NULL,
+ "created" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "last_modified" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "inactive" BOOLEAN NOT NULL DEFAULT FALSE,
+ "gm" BOOLEAN NOT NULL DEFAULT FALSE
+);
+
+CREATE TABLE IF NOT EXISTS "characters" (
+ "id" SERIAL PRIMARY KEY NOT NULL,
+ "name" VARCHAR(64) NOT NULL,
+ "account_id" INT NOT NULL REFERENCES accounts (id),
+ "faction_id" INT NOT NULL,
+ "gender_id" INT NOT NULL,
+ "head_id" INT NOT NULL,
+ "voice_id" INT NOT NULL,
+ "created" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "last_login" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "last_modified" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "deleted" BOOLEAN NOT NULL DEFAULT FALSE
+);
+
+CREATE TABLE IF NOT EXISTS "logins" (
+ "id" SERIAL PRIMARY KEY NOT NULL,
+ "account_id" INT NOT NULL REFERENCES accounts (id),
+ "login_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "ip_address" VARCHAR(32) NOT NULL,
+ "canonical_hostname" VARCHAR(132) NOT NULL,
+ "hostname" VARCHAR(132) NOT NULL,
+ "port" INT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS "loadouts" (
+ "id" SERIAL PRIMARY KEY NOT NULL,
+ "characters_id" INT NOT NULL REFERENCES characters (id),
+ "loadout_number" INT NOT NULL,
+ "exosuit_id" INT NOT NULL,
+ "name" VARCHAR(36) NOT NULL,
+ "items" TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS "lockers" (
+ "id" SERIAL PRIMARY KEY NOT NULL,
+ "characters_id" INT NOT NULL REFERENCES characters (id),
+ "items" TEXT NOT NULL
+);
+
+--These triggers update the last_modified timestamp column when a table is updated
+CREATE OR REPLACE FUNCTION fn_set_last_modified_timestamp()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.last_modified = NOW();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS trigger_accounts_set_last_modified on accounts;
+CREATE TRIGGER trigger_accounts_set_last_modified
+BEFORE UPDATE ON accounts
+FOR EACH ROW
+EXECUTE PROCEDURE fn_set_last_modified_timestamp();
+
+DROP TRIGGER IF EXISTS trigger_players_set_last_modified on characters;
+CREATE TRIGGER trigger_players_set_last_modified
+BEFORE UPDATE ON characters
+FOR EACH ROW
+EXECUTE PROCEDURE fn_set_last_modified_timestamp();