aboutsummaryrefslogtreecommitdiff
path: root/src/scalevalapokalypsi/Server
diff options
context:
space:
mode:
Diffstat (limited to 'src/scalevalapokalypsi/Server')
-rw-r--r--src/scalevalapokalypsi/Server/Client.scala173
-rw-r--r--src/scalevalapokalypsi/Server/Clients.scala82
-rw-r--r--src/scalevalapokalypsi/Server/ConnectionGetter.scala25
-rw-r--r--src/scalevalapokalypsi/Server/Server.scala195
4 files changed, 475 insertions, 0 deletions
diff --git a/src/scalevalapokalypsi/Server/Client.scala b/src/scalevalapokalypsi/Server/Client.scala
new file mode 100644
index 0000000..6ce2522
--- /dev/null
+++ b/src/scalevalapokalypsi/Server/Client.scala
@@ -0,0 +1,173 @@
+package scalevalapokalypsi.Server
+
+import java.net.Socket
+import scala.math.min
+import scalevalapokalypsi.constants.*
+import ServerProtocolState.*
+import scalevalapokalypsi.Model.{Action,Player,Entity}
+
+class Client(val socket: Socket):
+ private var incompleteMessage: Array[Byte] =
+ Array.fill(MAX_MSG_SIZE)(0.toByte)
+ private var incompleteMessageIndex = 0
+ private var protocolState = WaitingForVersion
+ private var outData: String = ""
+ private var character: Option[Player] = None
+ private var protocolIsIntact = true
+ private var name: Option[String] = None
+ private var nextAction: Option[Action] = None
+
+ /** Calculates the amount of bytes available for future incoming messages */
+ def spaceAvailable: Int = MAX_MSG_SIZE - incompleteMessageIndex
+
+ /** Tests whether the client has behaved according to protocol.
+ *
+ * @return false if there has been a protocol violation, true otherwise
+ */
+ def isIntactProtocolWise: Boolean = this.protocolIsIntact
+
+ /** Marks that this client misbehaved in eyes of the protocol */
+ def failedProtocol(): Unit = this.protocolIsIntact = false
+
+ /** Tests whether this client is initialized and ready to start the game
+ *
+ * @return true if the client is ready to join the game
+ */
+ def isReadyForGameStart: Boolean =
+ this.protocolState == WaitingForGameStart
+
+ /** Signals this client that it's joining the game. This is important so
+ * that this object knows to update its protocol state.
+ */
+ def gameStart(): Unit = this.protocolState = InGame
+
+ /** Returns the player this client controls in the model.
+ *
+ * @return an option containing the player
+ */
+ def player: Option[Player] = this.character
+
+ /** Tells this client object that it controls the specified player.
+ *
+ * @param player the player this client is to control
+ */
+ def givePlayer(player: Player): Unit =
+ this.character = Some(player)
+
+ /** Gets the name of this client, which should match the name of the player
+ * that is given to this client. Not very useful if the client hasn't yet
+ * received the name or if it already has an player.
+ *
+ * @return the name of this client
+ */
+ def getName: Option[String] = this.name
+
+ /** Sets `data` as received for the client.
+ *
+ * @return false means there was not enough space to receive the message
+ */
+ def receiveData(data: Vector[Byte]): Boolean =
+ for i <- 0 until min(data.length, spaceAvailable) do
+ this.incompleteMessage(this.incompleteMessageIndex + i) = data(i)
+ this.incompleteMessageIndex += data.length
+ this.incompleteMessageIndex =
+ min(this.incompleteMessageIndex, MAX_MSG_SIZE)
+ data.length < spaceAvailable
+
+ /** Returns data that should be sent to this client.
+ * The data is cleared when calling.
+ */
+ def dataToThisClient(): String =
+ val a = this.outData
+ this.outData = ""
+ a
+
+ /** Specifies that the data should be buffered for
+ * sending to this client
+ *
+ * @param data data to buffer for sending
+ */
+ private def addDataToSend(data: String): Unit =
+ this.outData += s"$data\r\n"
+
+
+ /** Returns one line of data if there are any line breaks.
+ * Removes the parsed data from the message buffering area.
+ */
+ private def nextLine(): Option[String] =
+ var nextCRLF = this.incompleteMessage.indexOf(CRLF(0))
+ if this.incompleteMessage(nextCRLF + 1) != CRLF(1) then nextCRLF = -1
+ if nextCRLF != -1 then
+ val message = this.incompleteMessage.take(nextCRLF)
+ val rest = this.incompleteMessage.drop(nextCRLF + 2)
+ this.incompleteMessage = rest ++ Array.fill(nextCRLF + 1)(0.toByte)
+ // TODO: the conversion may probably be exploited to crash the server
+ Some(String(message))
+ else
+ None
+
+ /** Makes the client play its turn */
+ def act(): Unit =
+ this.addDataToSend(ACTION_BLOCKING_INDICATOR.toString)
+ this.nextAction.foreach(a => this.addDataToSend(
+ s"$ACTION_BLOCKING_INDICATOR${this.executeAction(a)}"
+ ))
+ this.nextAction = None
+
+ /** Checks whether the client has chosen its next action
+ *
+ * @return whether the client is ready to act */
+ def isReadyToAct: Boolean = this.nextAction.isDefined
+
+ /** Causes the client to interpret the data it has received */
+ def interpretData(): Unit =
+ LazyList.continually(this.nextLine())
+ .takeWhile(_.isDefined)
+ .flatten
+ .foreach(s => interpretLine(s))
+
+ /** Makes the client execute the action specified by `line`.
+ * If there is a protocol error, the function changes
+ * the variable `protocolIsIntact` to false.
+ *
+ * @param line the line to interpret
+ */
+ private def interpretLine(line: String): Unit =
+ this.protocolIsIntact = this.protocolState match
+ case WaitingForVersion =>
+ if line == GAME_VERSION then
+ addDataToSend(s"$PROTOCOL_VERSION_GOOD")
+ this.protocolState = WaitingForClientName
+ true
+ else
+ addDataToSend(s"$PROTOCOL_VERSION_BAD")
+ false
+ case WaitingForClientName =>
+ this.name = Some(line)
+ this.protocolState = WaitingForGameStart
+ true
+ case WaitingForGameStart => true
+ case InGame =>
+ this.bufferAction(Action(line))
+ true
+
+ /** Buffers the action for execution or executes it immediately if it
+ * doesn't take a turn */
+ private def bufferAction(action: Action) =
+ if (
+ this.nextAction.isEmpty &&
+ this.player.exists(action.takesATurnFor(_))
+ ) then
+ this.nextAction = Some(action)
+ else if this.nextAction.isEmpty then
+ this.addDataToSend(s"$ACTION_NONBLOCKING_INDICATOR${this.executeAction(action)}")
+
+ /** Executes the specified action and returns its description */
+ private def executeAction(action: Action): String =
+ this.character.flatMap(action.execute(_)) match
+ case Some(s) => s
+ case None => "You can't do that"
+
+
+end Client
+
diff --git a/src/scalevalapokalypsi/Server/Clients.scala b/src/scalevalapokalypsi/Server/Clients.scala
new file mode 100644
index 0000000..377050d
--- /dev/null
+++ b/src/scalevalapokalypsi/Server/Clients.scala
@@ -0,0 +1,82 @@
+package scalevalapokalypsi.Server
+
+import scala.util.Try
+import scala.util.Random
+
+class Clients(maxClients: Int):
+ private val clients: Array[Option[Client]] = Array.fill(maxClients)(None)
+
+ /** Adds `client` to this collection of clients.
+ *
+ * @param client the Client to add
+ * @return true if there was room for the client
+ * i.e. fewer clients than `maxClients`, false otherwise
+ */
+ def addClient(client: Client): Boolean =
+ val i = this.clients.indexOf(None)
+ if i == -1 then
+ false
+ else
+ this.clients(i) = Some(client)
+ true
+
+ /** Returns all the clients.
+ *
+ * @return an iterable of all the clients
+ */
+ def allClients: Iterable[Client] = clients.toVector.flatten
+
+ /** Applies the function `f` to all the clients for its side effects. */
+ def foreach(f: Client => Any): Unit = this.clients.flatten.foreach(f)
+
+ /** Executes the function `f` for all clients in a pseudorandom order. */
+ def inRandomOrder(f: Client => Any): Unit =
+ Random.shuffle(this
+ .clients
+ .flatten)
+ .foreach(f)
+
+ /** Returns true if the predicate `f` stands for all clients,
+ * false otherwise
+ *
+ * @param f the predicate to check for all clients
+ * @return whether `f` stands for all clients
+ */
+ def forall(f: Client => Boolean): Boolean = this.clients.flatten.forall(f)
+
+ /** Gets the names of all the clients stored by this object.
+ *
+ * @return the names of the clients
+ */
+ def names: Vector[String] = this.clients.flatten.flatMap(_.getName).toVector
+
+ def isEmpty: Boolean = this.clients.flatten.isEmpty
+
+ /** Applies the function `f` to all the clients for its side effects
+ * and removes all the clients for which `f([client])` returns false.
+ * This is useful for doing IO with the client and removing clients
+ * with stale sockets.
+ *
+ * @param f the function to apply to all the clients and filter them with
+ */
+ def removeNonSatisfying(f: Client => Boolean): Unit =
+ for i <- this.clients.indices do
+ this.clients(i) match
+ case Some(c) =>
+ if !f(c) then
+ this.clients(i) = None
+ case None =>
+
+ /** Removes clients that have not behaved according to protocol */
+ def removeNonCompliant(): Unit =
+ this.removeNonSatisfying(_.isIntactProtocolWise)
+
+ /** Applies the function f to all clients for its side effects.
+ * If the function throws an exception, the client is removed.
+ * Probably a more concise alternative to `removeNonSatisfying`,
+ * but might catch exceptions unintentionally.
+ *
+ * @param f the function to apply for its side effects to each client
+ */
+ def mapAndRemove(f: Client => Unit): Unit =
+ this.removeNonSatisfying(c => Try(f(c)).isSuccess)
diff --git a/src/scalevalapokalypsi/Server/ConnectionGetter.scala b/src/scalevalapokalypsi/Server/ConnectionGetter.scala
new file mode 100644
index 0000000..40830c7
--- /dev/null
+++ b/src/scalevalapokalypsi/Server/ConnectionGetter.scala
@@ -0,0 +1,25 @@
+package scalevalapokalypsi.Server
+
+import java.io.IOException
+import java.net.{ServerSocket, Socket}
+import scala.concurrent.Future
+import scala.util.{Failure, Success}
+import scala.concurrent.ExecutionContext.Implicits.global
+
+/** Small helper class for getting new connections using futures */
+class ConnectionGetter(val socket: ServerSocket):
+
+ private var nextClient: Future[Socket] = Future.failed(IOException())
+
+ /** Returns a new socket to a client if there is any new connections. */
+ def newClient(): Option[Socket] =
+ this.nextClient.value match
+ case Some(Success(s)) =>
+ nextClient = Future(socket.accept())
+ Some(s)
+ case Some(Failure(e)) =>
+ nextClient = Future(socket.accept())
+ None
+ case None => None
+
+end ConnectionGetter
diff --git a/src/scalevalapokalypsi/Server/Server.scala b/src/scalevalapokalypsi/Server/Server.scala
new file mode 100644
index 0000000..13ca2f5
--- /dev/null
+++ b/src/scalevalapokalypsi/Server/Server.scala
@@ -0,0 +1,195 @@
+package scalevalapokalypsi.Server
+
+
+// TODO: TLS/SSL / import javax.net.ssl.SSLServerSocketFactory
+
+import java.lang.Thread.{currentThread, sleep}
+import java.io.IOException
+import java.net.{ServerSocket, Socket}
+import scalevalapokalypsi.constants.*
+import scalevalapokalypsi.Model.{Adventure,Entity,Player}
+import scalevalapokalypsi.utils.stringToByteArray
+
+import java.lang.System.currentTimeMillis
+import scala.util.Try
+
+
+/** `Server` exists to initialize a server for the game
+ * and run it with its method `startServer`.
+ *
+ * @param port the TCP port the server should listen on
+ * @param maxClients the maximum number of clients that may be in the game
+ * simultaneously.
+ * @param timeLimit the time limit clients should have to execute their turns.
+ * @param joinAfterStart whether new clients are accepted after the game has
+ * been started
+ */
+class Server(
+ port: Int,
+ maxClients: Int,
+ val timeLimit: Int,
+ val joinAfterStart: Boolean
+):
+
+ private val socket = ServerSocket(port)
+ private val clientGetter = ConnectionGetter(socket)
+ private val clients: Clients = Clients(maxClients)
+ private val buffer: Array[Byte] = Array.ofDim(1024)
+ private var bufferIndex = 0
+ private var adventure: Option[Adventure] = None
+ private var previousTurn = 0.0
+
+ /** Starts the server. Won't terminate under normal circumstances. */
+ def startServer(): Unit =
+ while true do
+ this.serverStep()
+ sleep(POLL_INTERVAL)
+
+ private def serverStep(): Unit =
+ this.clients.removeNonCompliant()
+ if this.adventure.isEmpty || this.joinAfterStart then
+ this.receiveNewClient()
+ this.readFromAll()
+ this.clients.foreach(_.interpretData())
+ this.writeClientDataToClients()
+ this.writeObservations()
+ if this.canExecuteTurns then
+ this.clients.inRandomOrder(_.act())
+ this.writeClientDataToClients()
+ this.writeObservations()
+ this.clients.foreach(c =>
+ this.writeToClient(this.turnStartInfo(c), c)
+ )
+ this.previousTurn = currentTimeMillis() / 1000
+ if this.adventure.isDefined && this.joinAfterStart then
+ this.clients.foreach( c => if c.isReadyForGameStart then
+ this.adventure.foreach(a =>
+ c.getName.foreach(n => a.addPlayer(n))
+ )
+ startGameForClient(c)
+ )
+ else if this.adventure.isEmpty && !this.clients.isEmpty && this.clients.forall(_.isReadyForGameStart) then
+ this.adventure = Some(Adventure(this.clients.names))
+ this.clients.foreach(startGameForClient(_))
+ this.previousTurn = currentTimeMillis() / 1000
+
+ /** Helper function to start the game for the specified client c.
+ * MAY ONLY BE USED IF `this.adventure` is Some!
+ * Apparently guard clauses are bad because they use return or something,
+ * but assertions should be fine, as long as they enforce the function
+ * contract?
+ */
+ private def startGameForClient(c: Client): Unit =
+ assert(this.adventure.isDefined)
+ c.gameStart()
+ val name = c.getName
+
+ val playerEntity: Option[Player] = name match
+ case Some(n) => this.adventure match
+ case Some(a) => a.getPlayer(n)
+ case None => None
+ case None => None
+ playerEntity.foreach(c.givePlayer(_))
+
+ this.writeToClient(
+ s"$timeLimit\r\n${this.turnStartInfo(c)}", c
+ )
+
+ this.clients.foreach(c =>
+ if c.player != playerEntity then
+ c.player.foreach(_.observe(s"${name.getOrElse("Unknown player")} joins the game."))
+ )
+
+
+ private def writeObservations(): Unit =
+ this.clients.foreach(c =>
+ val observations = c.player.map(_.readAndClearObservations())
+ observations.foreach(_.foreach((s: String) =>
+ this.writeToClient(s"$ACTION_NONBLOCKING_INDICATOR$s\r\n", c))
+ )
+ )
+
+ /** Helper function to determine if the next turn can be taken */
+ private def canExecuteTurns: Boolean =
+ val requirement1 = this.adventure.isDefined
+ val requirement2 = !this.clients.isEmpty // nice! you can just return
+ // to the game after everyone
+ // left and everything is just
+ // as before!
+ val allPlayersReady = this.clients.forall(_.isReadyToAct)
+ val requirement3 = (allPlayersReady
+ || currentTimeMillis() / 1000 >= previousTurn + timeLimit)
+ requirement1 && requirement2 && requirement3
+
+
+ /** Receives a new client and stores it in `clients`.
+ *
+ * @return describes if a client was added
+ */
+ private def receiveNewClient(): Boolean =
+ this.clientGetter.newClient() match
+ case Some(c) =>
+ clients.addClient(Client(c))
+ true
+ case None =>
+ false
+
+ private def turnStartInfo(client: Client): String =
+ val clientArea = client.player.map(_.location)
+ val areaDesc = clientArea
+ .map(_.description)
+ .getOrElse("You are floating in the middle of a soothing void.")
+ val directions = clientArea
+ .map(_.getNeighborNames.mkString(LIST_SEPARATOR))
+ .getOrElse("")
+ val items = clientArea
+ .map(_.getItemNames.mkString(LIST_SEPARATOR))
+ .getOrElse("")
+ val entities = client.player.map(c =>
+ c.location
+ .getEntityNames
+ .filter(c.name != _)
+ .mkString(LIST_SEPARATOR)
+ ).getOrElse("")
+ s"$TURN_INDICATOR\r\n$areaDesc\r\n$directions\r\n$items\r\n$entities\r\n"
+
+ /** Sends `message` to all clients
+ *
+ * @param message the message to send
+ */
+ private def writeToAll(message: String): Unit =
+ this.clients.mapAndRemove(c =>
+ val output = c.socket.getOutputStream
+ output.write(stringToByteArray(message))
+ output.flush()
+ )
+
+ private def writeToClient(message: String, client: Client): Unit =
+ try {
+ val output = client.socket.getOutputStream
+ output.write(stringToByteArray(message))
+ output.flush()
+ } catch {
+ case e: IOException => client.failedProtocol()
+ }
+
+ /** Sends every client's `dataToThisClient` to the client */
+ private def writeClientDataToClients(): Unit =
+ this.clients.mapAndRemove(c =>
+ val output = c.socket.getOutputStream
+ val data = c.dataToThisClient()
+ output.write(stringToByteArray(data))
+ output.flush()
+ )
+
+ /** Reads data sent by clients and stores it in the `Client`s of `clients` */
+ private def readFromAll(): Unit =
+ clients.mapAndRemove(c =>
+ val input = c.socket.getInputStream
+ while input.available() != 0 do
+ val bytesRead = input.read(buffer)
+ if bytesRead != -1 then
+ c.receiveData(buffer.take(bytesRead).toVector)
+ )
+
+end Server