Account and Character Database and Config Improvements (#317)

* Create Account/DB abstraction

* Fix crash when removing boomers from deconstructed player

* Extend config to include database and worldserver info

* Improve ConfigParser tests

* Add database setup documentation

* Add xTriad to THANKS file

**

* Increase bcrypt rounds and fix readme link
This commit is contained in:
pschord 2020-01-10 11:13:37 -05:00 committed by Fate-JH
parent ae768e1e42
commit d08911d07c
29 changed files with 1757 additions and 225 deletions

View file

@ -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
})
}
}

View file

@ -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)
}

View file

@ -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
)
)
)

View file

@ -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")

View file

@ -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
}

View file

@ -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("")

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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)
)
)
}