diff options
Diffstat (limited to 'src/scalevalapokalypsi/Server')
-rw-r--r-- | src/scalevalapokalypsi/Server/Client.scala | 173 | ||||
-rw-r--r-- | src/scalevalapokalypsi/Server/Clients.scala | 82 | ||||
-rw-r--r-- | src/scalevalapokalypsi/Server/ConnectionGetter.scala | 25 | ||||
-rw-r--r-- | src/scalevalapokalypsi/Server/Server.scala | 195 |
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 |