diff options
author | Joel Kronqvist <joel.kronqvist@iki.fi> | 2024-11-09 16:32:09 +0200 |
---|---|---|
committer | Joel Kronqvist <joel.kronqvist@iki.fi> | 2024-11-09 16:32:09 +0200 |
commit | 129b49a3876ceb68f271c311d5e45efb2e205300 (patch) | |
tree | 246a47a5990167c73b95fc6e02452de7f6aca6b3 | |
parent | 239571e3408a3187953bef1dd5d516461bad0e31 (diff) | |
download | scalevalapokalypsi-129b49a3876ceb68f271c311d5e45efb2e205300.tar.gz scalevalapokalypsi-129b49a3876ceb68f271c311d5e45efb2e205300.zip |
Made & implemented clearer protocol, added client functionality
-rw-r--r-- | protocol.txt | 15 | ||||
-rw-r--r-- | src/main/scala/Client/Client.scala | 210 | ||||
-rw-r--r-- | src/main/scala/Model/Area.scala | 4 | ||||
-rw-r--r-- | src/main/scala/Server/Client.scala | 19 | ||||
-rw-r--r-- | src/main/scala/Server/Server.scala | 56 | ||||
-rw-r--r-- | src/main/scala/Server/constants.scala | 10 | ||||
-rw-r--r-- | src/main/scala/main.scala | 22 | ||||
-rw-r--r-- | src/main/scala/utils.scala | 9 |
8 files changed, 295 insertions, 50 deletions
diff --git a/protocol.txt b/protocol.txt new file mode 100644 index 0000000..15cc75c --- /dev/null +++ b/protocol.txt @@ -0,0 +1,15 @@ +Client: [version number]CRLF[client name|] +Server: [good/version old] +... +Server: [time limit in int/secs]CRLF # signifies game start + +Before turn: +N x [Description of action during previous turn]CRLF +At start of turn: +Server: [turn indicator]CRLF + [Description of area]CRLF + [Directions separated with semicolon]CRLF + [Visible items separated with semicolon]CRLF + [Entities separated with semicolon]CRLF + +When running turn: [CRLF-separated list of things happening in the players room] diff --git a/src/main/scala/Client/Client.scala b/src/main/scala/Client/Client.scala index 1b843ab..ef98ef3 100644 --- a/src/main/scala/Client/Client.scala +++ b/src/main/scala/Client/Client.scala @@ -3,26 +3,216 @@ package o1game.Client import java.lang.Thread.sleep import java.net.Socket import scala.io.Source +import scala.io.StdIn.readLine import scala.sys.process.stdout +import java.io.InputStream import o1game.constants.* +import o1game.utils.stringToByteArray +import scala.concurrent.Future +import scala.concurrent.ExecutionContext.Implicits.global +import scala.util.{Try, Success, Failure} +import scala.collection.mutable.Buffer +import scala.collection.immutable.LazyList +import java.lang.System.currentTimeMillis -class Client(ip: String, port: Int): - private val socket = Socket(ip, port) +def newClient(name: String, ip: String, port: Int): Option[Client] = + val socket = Socket(ip, port) + val output = socket.getOutputStream + val input = socket.getInputStream + val initMsg = s"$GAME_VERSION\r\n$name\r\n" + output.write(stringToByteArray(initMsg)) + val msgLen = (PROTOCOL_VERSION_GOOD + "\r\n").length + val versionResponse = getNCharsFromSocket(input, msgLen) + if versionResponse == Some(s"$PROTOCOL_VERSION_GOOD\r\n") then + Some(Client(socket)) + else + None + +def getNCharsFromSocket(input: InputStream, n: Int): Option[String] = + val buffer: Array[Byte] = Array.ofDim(n) + var i = 0 + var failed = false + while i < n && !failed do + val res = input.read(buffer, i, n - i) + if res < 0 then failed = true + i += res + // TODO: better error handling + if failed then None else Some(String(buffer)) + +/** This class is for taking new lines from stdin when they are available. + * reading starts when either newLine or clear or startReading are called. + */ +class StdinLineReader: + + private var nextLine: Future[String] = Future.failed(Exception()) + + /** Returns a new line of input if there are any. */ + def newLine(): Option[String] = + this.nextLine.value match + case Some(Success(s)) => + this.nextLine = Future(readLine()) + Some(s) + case Some(Failure(e)) => + this.nextLine = Future(readLine()) + None + case None => None + + /** Discards the line that is currently being read and restarts reading */ + def clear(): Unit = + this.nextLine = Future(readLine()) + + /** Equivalent to clear */ + def startReading(): Unit = this.clear() + +end StdinLineReader + +enum ServerLineState: + case WaitingForGameStart, + ActionDescription, + TurnIndicator, + AreaDescription, + Directions, + Items, + Entities + + +//def indexOfCRLF(data: IndexedSeq[Byte]): Int = +// val LF = data.indexOf(10) +// data.get(LF + 1).filter(_ == 13).filter(a => LF == -1).getOrElse(-1) +// +//def splitAtCRLF(data: IndexedSeq[Byte]): Vector[] + +class ServerDataParser: + + private var serverLineState = ServerLineState.ActionDescription + + private var bufferedData: Buffer[Byte] = Buffer.empty // TODO: suboptimal DS + + def in(data: Array[Byte]): Unit = + this.bufferedData ++= data + + def nextLine(): Option[String] = + val indexOfCRLF = this.bufferedData.indexOfSlice(CRLF) + if indexOfCRLF == -1 then + None + else + val splitData = this.bufferedData.splitAt(indexOfCRLF) + this.bufferedData = Buffer.from(splitData(1).drop(CRLF.length)) + Some(String(splitData(0).toArray)) + +end ServerDataParser + +class Client(socket: Socket): private val input = socket.getInputStream private val output = socket.getOutputStream private val buffer: Array[Byte] = Array.ofDim(MAX_MSG_SIZE) private var bufferIndex = 0 + private var serverLineState = ServerLineState.WaitingForGameStart + private val serverLineParser = ServerDataParser() + private val stdinReader = StdinLineReader() + private var timeLimit: Long = 0 + + private var lastTurnStart: Long = 0 + private var lastExecutedTurn: Long = 0 + assert( + lastTurnStart <= lastExecutedTurn, + "don't initialize with unexecuted turn" + ) + + // TODO: extract these to a separate area object for the client + private var actions: Buffer[String] = Buffer.empty + private var areaDescription: String = "" + private var possibleDirections: Array[String] = Array.empty + private var visibleItems: Array[String] = Array.empty + private var visibleEntities: Array[String] = Array.empty + + + private def readAndParseDataFromServer(): Unit = + var availableBytes = input.available() + while availableBytes != 0 do + val bytesRead = input.read(buffer, 0, availableBytes) + if bytesRead != -1 then + // TODO: unsafe conversion + parseDataFromServer(buffer.take(bytesRead)) + availableBytes = input.available() + + private def giveTurn(): String = + this.lastExecutedTurn = currentTimeMillis / 1000 + val actionDesc = this.actions.mkString("\n") + this.actions = Buffer.empty + val itemDesc = "You can see the following items: " + + this.visibleItems.mkString(", ") + val entityDesc = "The following entities reside in the room: " + + this.visibleEntities.mkString(", ") + val directionDesc = "There are exits to " + + this.possibleDirections.mkString(", ") + (s"\n$actionDesc\n\n$areaDescription\n$directionDesc\n" + + s"\n$itemDesc\n$entityDesc") + def startClient(): Unit = + stdinReader.startReading() + + // TODO: read data from server and store it in the turn description + // TODO: if turn isn't executed since lastturnstart, display the data + // and clean STDIN + // TODO: write data from stdin and send it to the server + // TODO: display timer to next turn end while true do sleep(POLL_INTERVAL) - while input.available() != 0 do - val bytesRead = input.read(buffer) - if bytesRead != -1 then - print(buffer.take(bytesRead).toVector.map(_.toChar).mkString) - stdout.flush() + this.readAndParseDataFromServer() + + if this.lastExecutedTurn < this.lastTurnStart then + println(this.giveTurn()) + + stdinReader.newLine().foreach((s: String) => + output.write(stringToByteArray(s+"\r\n")) + ) + + if this.timeLimit != 0 && this.lastTurnStart != 0 then + val timeOfTurnEnd = this.lastTurnStart + this.timeLimit + val timeToTurnEnd = -currentTimeMillis()/1000 + timeOfTurnEnd + print(s"\r[$timeToTurnEnd]> ") + + private def parseDataFromServer(data: Array[Byte]): Unit = + this.serverLineParser.in(data) + var nextLine: Option[String] = Some("") + while nextLine.isDefined do + nextLine = this.serverLineParser + .nextLine() + nextLine + .foreach(this.parseLineFromServer(_)) + + private def parseLineFromServer(line: String) = + if line == TURN_INDICATOR then + this.serverLineState = ServerLineState.TurnIndicator + serverLineState match + case ServerLineState.WaitingForGameStart => + val time = line.toLongOption + time match + case Some(t) => this.timeLimit = t + case None => print("Invalid time limit, oh no!!!") + this.serverLineState = ServerLineState.ActionDescription + case ServerLineState.ActionDescription => + this.actions.append(line) + case ServerLineState.TurnIndicator => + this.serverLineState = ServerLineState.AreaDescription + case ServerLineState.AreaDescription => + this.areaDescription = line + this.serverLineState = ServerLineState.Directions + case ServerLineState.Directions => + this.possibleDirections = line.split(LIST_SEPARATOR) + this.serverLineState = ServerLineState.Items // TODO: maybe use a list instead? + case ServerLineState.Items => + this.visibleItems = line.split(LIST_SEPARATOR) + this.serverLineState = ServerLineState.Entities + case ServerLineState.Entities => + this.visibleEntities = line.split(LIST_SEPARATOR) + this.serverLineState = ServerLineState.ActionDescription + this.lastTurnStart = currentTimeMillis() / 1000 + - bufferIndex = s"Houston, I think this shouldn't be so hard.\n".toVector.map(_.toByte).copyToArray(buffer) - output.write(buffer, 0, bufferIndex) - output.flush()
\ No newline at end of file + //bufferIndex = s"Houston, I think this shouldn't be so hard.\n".toVector.map(_.toByte).copyToArray(buffer) + //output.write(buffer, 0, bufferIndex) + //output.flush() diff --git a/src/main/scala/Model/Area.scala b/src/main/scala/Model/Area.scala index f5b5289..ae1c98e 100644 --- a/src/main/scala/Model/Area.scala +++ b/src/main/scala/Model/Area.scala @@ -20,6 +20,10 @@ class Area(val name: String, var description: String): def neighbor(direction: String): Option[Area] = this.neighbors.get(direction) + def getNeighborNames: Iterable[String] = this.neighbors.keys + def getItemNames: Iterable[String] = this.items.keys + def getEntityNames: Iterable[String] = this.entities.keys + /** Tells whether this area has a neighbor in the given direction. * * @param direction the direction to check diff --git a/src/main/scala/Server/Client.scala b/src/main/scala/Server/Client.scala index e557c22..cd557c6 100644 --- a/src/main/scala/Server/Client.scala +++ b/src/main/scala/Server/Client.scala @@ -53,7 +53,6 @@ class Client(val socket: Socket): * @param entity the entity this client is to control */ def giveEntity(entity: Entity): Unit = - println(entity) this.character = Some(entity) /** Gets the name of this client, which should match the name of the entity @@ -90,18 +89,19 @@ class Client(val socket: Socket): * @param data data to buffer for sending */ private def addDataToSend(data: String): Unit = - this.outData += s"$data\n" + 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] = - val nextLF = this.incompleteMessage.indexOf(LF) - if nextLF != -1 then - val message = this.incompleteMessage.take(nextLF) - val rest = this.incompleteMessage.drop(nextLF + 1) - this.incompleteMessage = rest ++ Array.fill(nextLF + 1)(0.toByte) + 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 + 1) + this.incompleteMessage = rest ++ Array.fill(nextCRLF + 1)(0.toByte) // TODO: the conversion may probably be exploited to crash the server Some(String(message)) else @@ -157,17 +157,14 @@ class Client(val socket: Socket): this.entity.exists(action.takesATurnFor(_)) ) then this.nextAction = Some(action) - this.addDataToSend("Waiting for everyone to end their turns...") else if this.nextAction.isEmpty then executeAction(action) - /** Executes the specified action */ + /** Executes the specified action and buffers its description for sending */ private def executeAction(action: Action) = this.character.flatMap(action.execute(_)) match case Some(s) => this.addDataToSend((s)) case None => this.addDataToSend("You can't do that") - this.character.map(_.location.fullDescription) - .foreach(this.addDataToSend(_)) end Client diff --git a/src/main/scala/Server/Server.scala b/src/main/scala/Server/Server.scala index faf82e1..a03bc53 100644 --- a/src/main/scala/Server/Server.scala +++ b/src/main/scala/Server/Server.scala @@ -4,21 +4,16 @@ package o1game.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 o1game.constants.* import o1game.Model.Adventure import o1game.Model.Entity +import o1game.utils.stringToByteArray import java.lang.System.currentTimeMillis import scala.util.Try -/** Converts this string to an array of bytes (probably for transmission). - * - * @param str the string to convert - * @return an array of bytes representing the string in UTF8. - */ -def stringToByteArray(str: String): Array[Byte] = - str.toVector.map(_.toByte).toArray /** `Server` exists to initialize a server for the game * and run it with its method `startServer`. @@ -52,17 +47,19 @@ class Server( sleep(POLL_INTERVAL) private def serverStep(): Unit = + this.clients.removeNonCompliant() if this.adventure.isEmpty || this.joinAfterStart then this.receiveNewClient() this.readFromAll() - this.writeClientDataToClients() - this.clients.removeNonCompliant() this.clients.foreach(_.interpretData()) + this.writeClientDataToClients() if this.canExecuteTurns then - println("taking turns") - this.clients.inRandomOrder(_.act()) - this.writeToAll("next turn!") - this.previousTurn = currentTimeMillis() / 1000 + this.clients.inRandomOrder(_.act()) + this.writeClientDataToClients() + 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 => @@ -91,7 +88,7 @@ class Server( case None => None case None => None entity.foreach(c.giveEntity(_)) - this.writeToClient(s"$timeLimit", c) + this.writeToClient(s"$timeLimit\r\n", c) /** Helper function to determine if the next turn can be taken */ @@ -119,6 +116,25 @@ class Server( case None => false + private def turnStartInfo(client: Client): String = + val clientArea = client.entity.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.entity.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 @@ -131,13 +147,13 @@ class Server( ) private def writeToClient(message: String, client: Client): Unit = - val success = Try( () => + try { val output = client.socket.getOutputStream - output.write(stringToByteArray(message)) + output.write(message.toVector.map(_.toByte).toArray) output.flush() - ) - if success.isFailure then - client.failedProtocol() + } catch { + case e: IOException => client.failedProtocol() + } /** Sends every client's `dataToThisClient` to the client */ private def writeClientDataToClients(): Unit = @@ -158,4 +174,4 @@ class Server( c.receiveData(buffer.take(bytesRead).toVector) ) -end Server
\ No newline at end of file +end Server diff --git a/src/main/scala/Server/constants.scala b/src/main/scala/Server/constants.scala index ddaab00..a9e4502 100644 --- a/src/main/scala/Server/constants.scala +++ b/src/main/scala/Server/constants.scala @@ -2,12 +2,16 @@ package o1game.constants val MAX_MSG_SIZE = 1024 // bytes -val LF: Byte = 10 +val CRLF: Vector[Byte] = Vector(13.toByte, 10.toByte) val POLL_INTERVAL = 100 // millisec. val GAME_VERSION = "0.1.0" +val TURN_INDICATOR = ">" -val PROTOCOL_VERSION_GOOD = "version ok" -val PROTOCOL_VERSION_BAD = "version bad" +val LIST_SEPARATOR=";" + +val PROTOCOL_VERSION_GOOD = "1" +val PROTOCOL_VERSION_BAD = "0" +//assert(PROTOCOL_VERSION_BAD.length <= PROTOCOL_VERSION_GOOD.length) enum ServerProtocolState: case WaitingForVersion, WaitingForClientName, WaitingForGameStart, InGame diff --git a/src/main/scala/main.scala b/src/main/scala/main.scala index 145dc1c..362c270 100644 --- a/src/main/scala/main.scala +++ b/src/main/scala/main.scala @@ -1,15 +1,25 @@ -import o1game.Client.Client +import o1game.Client.newClient import o1game.Server.Server +import scala.concurrent.Future +import scala.concurrent.ExecutionContext.Implicits.global import scala.io.StdIn.readLine // TODO: add proper logic for starting the game @main def main(): Unit = - print("Please choose:\n1) Client.Client\n2) Server\n> ") - readLine().toIntOption match - case Some(1) => Client("127.0.0.1", 2267).startClient() - case Some(2) => Server(2267, 5, 30, true).startServer() - case _ => println("Invalid input") + print("How do you want to play?\n1) Host and join local game\n2) Join local game\n> ") + readLine().toIntOption match + case Some(1) => + Future(Server(2267, 5, 30, true).startServer()) + println("Server started in background.") + print("Choose a name:\n> ") + val name = readLine() + newClient(name, "127.0.0.1", 2267).map(_.startClient()) + case Some(2) => + print("Choose a name:\n> ") + val name = readLine() + newClient(name, "127.0.0.1", 2267).map(_.startClient()) + case _ => println("Invalid input") diff --git a/src/main/scala/utils.scala b/src/main/scala/utils.scala new file mode 100644 index 0000000..f230be7 --- /dev/null +++ b/src/main/scala/utils.scala @@ -0,0 +1,9 @@ +package o1game.utils + +/** Converts this string to an array of bytes (probably for transmission). + * + * @param str the string to convert + * @return an array of bytes representing the string in UTF8. + */ +def stringToByteArray(str: String): Array[Byte] = + str.toVector.map(_.toByte).toArray |