package app.actors import java.util.UUID import akka.actor._ import akka.pattern._ import netmsg.ProtoChecksum import spire.math.UInt import scala.concurrent.Future import scala.concurrent.duration._ import akka.event.LoggingReceive import akka.io.Tcp.Unbind import akka.util.Timeout import app.actors.NetClient.Management.{SessionToken, PlainPassword, Credentials} import app.actors.game.{GamesManagerActor, GameActor} import app.models._ import app.models.game.Human import app.persistence.tables.Tables import implicits._ import scala.reflect.ClassTag import scalaz._, Scalaz._ import app.persistence.DBDriver._ import org.joda.time.DateTime import scala.util.Try object NetClient { type GameInMsg = Human => GameActor.In object Control { case class SecretKey(key: String) extends AnyVal sealed trait In object In { case object Shutdown extends In case object Status extends In } sealed trait Out object Out { case class GenericReply(success: Boolean, message: Option[String]) extends Out object GenericReply { val success = GenericReply(success = true, None) def error(msg: String) = GenericReply(success = false, Some(msg)) } case class Status( tcpClients: Option[UInt], playingUsers: Option[UInt], games: Option[UInt] ) extends Out { override def toString = { import Status.asStr s"Status[tcp clients: ${asStr(tcpClients)}, playing users: ${ asStr(playingUsers)}, games: ${asStr(games)}]" } } object Status { def asStr(o: Option[UInt]) = o.fold2("-", _.toString()) } } } object Management { sealed trait AuthToken case class SessionToken(value: String) extends AuthToken object SessionToken { def random() = SessionToken(UUID.randomUUID().shortStr) } case class PlainPassword(value: String) extends AuthToken { import com.github.t3hnar.bcrypt._ def encrypted = value.bcrypt def check(hash: String) = value.isBcrypted(hash) } case class Credentials(name: String, auth: AuthToken) { def check(sessionToken: String, passwordHash: String): Boolean = auth match { case SessionToken(token) => sessionToken == token case password: PlainPassword => password.check(passwordHash) } } sealed trait In object In { case object AutoRegister extends In case class CheckNameAvailability(name: String) extends In case class Register( username: String, password: PlainPassword, email: String ) extends In case class Login(credentials: Credentials) extends In object JoinGame { sealed trait Mode sealed trait PvPMode extends Mode { def playersPerTeam: Int def teams: Int def playersNeeded = teams * playersPerTeam } object Mode { case object Singleplayer extends Mode case object OneVsOne extends PvPMode { def playersPerTeam = 1; def teams = 2 } } } case class JoinGame(mode: JoinGame.Mode) extends In case object CancelJoinGame extends In // After client logs in it should cancel the active background token. case class CancelBackgroundToken(token: GamesManagerActor.BackgroundToken) extends In } sealed trait Out object Out { case class CheckNameAvailabilityResponse(name: String, available: Boolean) extends Out case class RegisterResponse(newToken: Option[SessionToken]) extends Out sealed trait LoginResponse extends Out case object InvalidCredentials extends LoginResponse case class LoggedIn( user: User, token: SessionToken, autogenerated: Boolean ) extends LoginResponse case class GameJoined(human: Human) extends Out case object JoinGameCancelled extends Out case class WaitingListJoined(token: GamesManagerActor.BackgroundToken) extends Out } } object Msgs { sealed trait FromClient extends Serializable object FromClient { case object ProtoVersionCheck extends FromClient case class Game(msg: GameInMsg) extends FromClient case class Management(msg: NetClient.Management.In) extends FromClient case class TimeSync(clientNow: DateTime) extends FromClient } case class FromControlClient(key: NetClient.Control.SecretKey, msg: NetClient.Control.In) sealed trait FromServer object FromServer { case class ProtoVersionCheck(checksum: String) extends FromServer case class Game(msg: GameActor.ClientOut) extends FromServer case class Management(msg: NetClient.Management.Out) extends FromServer case class TimeSync(clientNow: DateTime, serverNow: DateTime) extends FromServer } } } class NetClient( msgHandler: ActorRef, gamesManager: ActorRef, server: ActorRef, controlKey: NetClient.Control.SecretKey, db: Database ) extends Actor with ActorLogging { import app.actors.NetClient.Management.In._ import app.actors.NetClient.Management.Out._ import app.actors.NetClient.Msgs._ import app.actors.NetClient._ implicit class ServerMsgExts(msg: FromServer) { def out(): Unit = msgHandler ! MsgHandler.Server2Client.GameMsg(msg) } implicit class ManagementMsgExts(msg: Management.Out) { def out(): Unit = FromServer.Management(msg).out() } implicit class GameMsgExts(msg: GameActor.ClientOut) { def out(): Unit = FromServer.Game(msg).out() } implicit class ControlMsgExts(msg: Control.Out) { def out(): Unit = msgHandler ! MsgHandler.Server2Client.ControlMsg(msg) } context.watch(msgHandler) override def receive = notLoggedIn private[this] var shutdownInitiated = false private[this] var inGameOpt = Option.empty[(ActorRef, Human)] @throws[Exception](classOf[Exception]) override def postStop(): Unit = { if (shutdownInitiated) { inGameOpt.foreach { case (gameRef, human) => // Auto-concede if lost connection when shutdown is initiated. log.info("Auto conceding because lost connection in shutdown mode.") gameRef ! GameActor.In.Concede(human) } } } private[this] val common: Receive = { case FromClient.ProtoVersionCheck => FromServer.ProtoVersionCheck(ProtoChecksum.checksum).out() case FromClient.TimeSync(clientNow) => FromServer.TimeSync(clientNow, DateTime.now).out() case m: MsgHandler.Client2Server.BackgroundSFO => gamesManager ! m case FromClient.Management(m: NetClient.Management.In.CancelBackgroundToken) => gamesManager ! m case m: NetClient.Management.Out.WaitingListJoined => m.out() case Server.ShutdownInitiated => shutdownInitiated = true case c: FromControlClient => import context.dispatcher handleControl(c).onComplete { case util.Success(m) => m.out() case util.Failure(err) => log.error("Error while handling control message {}: {}", c, err) } } def handleControl(c: FromControlClient): Future[Control.Out] = { if (c.key === controlKey) c.msg match { case Control.In.Shutdown => server ! Unbind Future.successful(Control.Out.GenericReply.success) case Control.In.Status => import context.dispatcher def ask[Reply : ClassTag, Result]( ref: AskableActorRef, message: Any, f: Reply => Result ) = { ref.ask(message)(Timeout(3.seconds)).mapTo[Reply].map(r => Some(f(r))).recover { case e => log.error("Error while asking for {}: {}", message, e) None } } val clientsCountF = ask[Server.Out.ReportClientCount, UInt]( server, Server.In.ReportClientCount, r => r.clients ) val gamesCountF = ask[GamesManagerActor.Out.StatsReport, (UInt, UInt)]( gamesManager, GamesManagerActor.In.StatsReport, r => (r.users, r.games) ) (clientsCountF zip gamesCountF).map { case (clients, gameManagerOpt) => Control.Out.Status(clients, gameManagerOpt.map(_._1), gameManagerOpt.map(_._2)) } } else Future.successful(Control.Out.GenericReply.error(s"Invalid control key '${c.key}'")) } private[this] val notLoggedIn: Receive = { def logIn(user: User, sessionToken: SessionToken, autogenerated: Boolean): Unit = { context.become(loggedIn(user)) LoggedIn(user, sessionToken, autogenerated).out() gamesManager ! GamesManagerActor.In.CheckUserStatus(user) } LoggingReceive(({ case FromClient.Management(AutoRegister) => val password = PlainPassword(UUID.randomUUID().shortStr) val sessionToken = SessionToken.random() val id = UUID.randomUUID() val user = User(id, s"autogen-${id.shortStr}") val credentials = Credentials(user.name, password) db.withSession { implicit session => Tables.users. map(t => (t.user, t.sessionToken, t.password, t.email)). insert((user, sessionToken.value, password.encrypted, None)) } logIn(user, sessionToken, autogenerated = true) case FromClient.Management(Login(credentials)) => val optQ = Tables.users. filter(t => t.name === credentials.name). map(t => (t.id, t.sessionToken, t.email, t.password)) val idOpt = db.withSession(optQ.firstOption(_)).filter { case (_, sessionToken, _, pwHash) => credentials.check(sessionToken, pwHash) }.map(t => (t._1, SessionToken(t._2), t._3.isEmpty)) idOpt.fold2( InvalidCredentials.out(), { case (id, token, autogenerated) => logIn(User(id, credentials.name), token, autogenerated) } ) }: Receive) orElse common) } private[this] def loggedIn(user: User): Receive = LoggingReceive(({ case FromClient.Management(CheckNameAvailability(name)) => val query = Tables.users.map(_.name).filter(_ === name).exists val exists = db.withSession(query.run(_)) CheckNameAvailabilityResponse(name, ! exists).out() case FromClient.Management(Register(username, password, email)) => val token = SessionToken.random() val query = Tables.users. filter(t => t.id === user.id && t.email.isEmpty). map(t => (t.name, t.email, t.password, t.sessionToken)) val success = Try { db.withSession(query.update(( username, Some(email), password.encrypted, token.value ))(_)) }.getOrElse(0) === 1 RegisterResponse(if (success) Some(token) else None).out() case FromClient.Management(JoinGame(mode)) => gamesManager ! GamesManagerActor.In.Join(user, mode) case FromClient.Management(CancelJoinGame) => gamesManager ! GamesManagerActor.In.CancelJoinGame(user) case msg: JoinGameCancelled.type => msg.out() case GameActor.Out.Joined(human, game) => GameJoined(human).out() context.become(inGame(user, human, game)) }: Receive) orElse common) private[this] def inGame(user: User, human: Human, game: ActorRef): Receive = { inGameOpt = Some((game, human)) context.watch(game) LoggingReceive(({ case FromClient.Game(msgFn) => val msg = msgFn(human) game ! msg case msg: GameActor.ClientOut => msg.out() case Terminated if sender() == game => log.error("Game was terminated") inGameOpt = None context.become(loggedIn(user)) }: Receive) orElse common) } }