diff options
author | Joel Kronqvist <joel.kronqvist@iki.fi> | 2024-11-14 19:25:19 +0200 |
---|---|---|
committer | Joel Kronqvist <joel.kronqvist@iki.fi> | 2024-11-14 19:25:19 +0200 |
commit | a43812ed462630850edbf29bda182fbf1e5e1263 (patch) | |
tree | 20e84b60adee6fa31ceb970ffccffa5b6b583d86 | |
parent | c87263e9e493fe6c130f5ad6a523871c08987f4c (diff) | |
download | scalevalapokalypsi-a43812ed462630850edbf29bda182fbf1e5e1263.tar.gz scalevalapokalypsi-a43812ed462630850edbf29bda182fbf1e5e1263.zip |
Immediate printing of actions & no prompt on blocking action & refactoring
-rw-r--r-- | protocol.txt | 2 | ||||
-rw-r--r-- | src/main/scala/Client/Client.scala | 206 | ||||
-rw-r--r-- | src/main/scala/Client/ReceivedLineParser.scala | 27 | ||||
-rw-r--r-- | src/main/scala/Client/StdinLineReader.scala | 31 | ||||
-rw-r--r-- | src/main/scala/Client/Turn.scala | 32 | ||||
-rw-r--r-- | src/main/scala/Server/Client.scala | 14 | ||||
-rw-r--r-- | src/main/scala/Server/constants.scala | 2 | ||||
-rw-r--r-- | src/main/scala/utils.scala | 19 |
8 files changed, 205 insertions, 128 deletions
diff --git a/protocol.txt b/protocol.txt index 15cc75c..7b13ab2 100644 --- a/protocol.txt +++ b/protocol.txt @@ -4,7 +4,7 @@ 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 +N x [Action blocker indicator][Description of action during previous turn]CRLF At start of turn: Server: [turn indicator]CRLF [Description of area]CRLF diff --git a/src/main/scala/Client/Client.scala b/src/main/scala/Client/Client.scala index 2503cdf..e9d3074 100644 --- a/src/main/scala/Client/Client.scala +++ b/src/main/scala/Client/Client.scala @@ -3,69 +3,19 @@ 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 o1game.utils.{stringToByteArray,getNCharsFromSocket} +import o1game.Client.{ReceivedLineParser,StdinLineReader,Turn} 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 -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. +/** A helper enum for `Client` to keep track of communications with the server */ -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, @@ -76,81 +26,60 @@ enum ServerLineState: 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 +/** Creates a new client. + * + * @param name the name the client and its player should have + * @ip the ip of the server to connect to + * @port the port of the server to connect to + * @return the client created, if all was successful + */ +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 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 +/** Main class for the client: handles communication with the server + * and the player. Should be initialized with `newClient`. + * + * @param socket the socket the client uses + */ class Client(socket: Socket): + + /** Essential IO variables */ 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 serverLineParser = ReceivedLineParser() private val stdinReader = StdinLineReader() - private var timeLimit: Long = 0 + private var serverLineState = ServerLineState.WaitingForGameStart + + /** Variables about the status of the current turn for the client */ + private var canAct = false + private var timeLimit: Long = 0 private var lastTurnStart: Long = 0 private var lastExecutedTurn: Long = 0 assert( lastTurnStart <= lastExecutedTurn, "don't initialize with unexecuted turn" ) + private val turnInfo = 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\n") - + /** Starts the client. This shouldn't terminate. */ def startClient(): Unit = + stdinReader.startReading() while true do @@ -165,10 +94,34 @@ class Client(socket: Socket): 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]> ") + end startClient + + + 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.canAct = true + this.lastExecutedTurn = currentTimeMillis / 1000 + s"\n\n${this.turnInfo}\n${this.actionGetterIndicator}" + + private def displayAction(action: String): Unit = + println(action) + if this.canAct then + print(this.actionGetterIndicator) + + private def actionGetterIndicator = + val timeOfTurnEnd = this.lastTurnStart + this.timeLimit + val timeToTurnEnd = -currentTimeMillis()/1000 + timeOfTurnEnd + s"[$timeToTurnEnd]> " + + private def parseDataFromServer(data: Array[Byte]): Unit = this.serverLineParser.in(data) @@ -179,35 +132,46 @@ class Client(socket: Socket): 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) + if line.head == ACTION_BLOCKING_INDICATOR then + this.canAct = false + this.displayAction(line.tail) + case ServerLineState.TurnIndicator => this.serverLineState = ServerLineState.AreaDescription + case ServerLineState.AreaDescription => - this.areaDescription = line + this.turnInfo.areaDescription = line this.serverLineState = ServerLineState.Directions + case ServerLineState.Directions => - this.possibleDirections = line.split(LIST_SEPARATOR) + this.turnInfo.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.turnInfo.visibleItems = line.split(LIST_SEPARATOR) this.serverLineState = ServerLineState.Entities + case ServerLineState.Entities => - this.visibleEntities = line.split(LIST_SEPARATOR) + this.turnInfo.visibleEntities = line.split(LIST_SEPARATOR) this.serverLineState = ServerLineState.ActionDescription this.lastTurnStart = currentTimeMillis() / 1000 + end parseLineFromServer - //bufferIndex = s"Houston, I think this shouldn't be so hard.\n".toVector.map(_.toByte).copyToArray(buffer) - //output.write(buffer, 0, bufferIndex) - //output.flush() +end Client diff --git a/src/main/scala/Client/ReceivedLineParser.scala b/src/main/scala/Client/ReceivedLineParser.scala new file mode 100644 index 0000000..7cbf935 --- /dev/null +++ b/src/main/scala/Client/ReceivedLineParser.scala @@ -0,0 +1,27 @@ +package o1game.Client + +import scala.collection.mutable.Buffer +import o1game.constants.* + +/** A class for checking asynchronously for received lines */ +class ReceivedLineParser: + + private var serverLineState = ServerLineState.ActionDescription + + private var bufferedData: Buffer[Byte] = Buffer.empty // TODO: suboptimal DS + + /** Add received data */ + def in(data: Array[Byte]): Unit = + this.bufferedData ++= data + + /** Read a line from the received 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 ReceivedLineParser diff --git a/src/main/scala/Client/StdinLineReader.scala b/src/main/scala/Client/StdinLineReader.scala new file mode 100644 index 0000000..42a1f40 --- /dev/null +++ b/src/main/scala/Client/StdinLineReader.scala @@ -0,0 +1,31 @@ +package o1game.Client + +import scala.concurrent.Future +import scala.concurrent.ExecutionContext.Implicits.global +import scala.io.StdIn.readLine +import scala.util.{Try, Success, Failure} + +/** 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.startReading() + Some(s) + case Some(Failure(e)) => + this.startReading() + None + case None => None + + /** Discards the line that is currently being read and restarts reading */ + def startReading(): Unit = + this.nextLine = Future(readLine()) + + +end StdinLineReader diff --git a/src/main/scala/Client/Turn.scala b/src/main/scala/Client/Turn.scala new file mode 100644 index 0000000..6b78811 --- /dev/null +++ b/src/main/scala/Client/Turn.scala @@ -0,0 +1,32 @@ +package o1game.Client + +/** `Turn`s represent information the client has got about a turn. + * This class exists essentially so that the client has somewhere + * to store data about turns and something to format that data with. + */ +class Turn: + + /** Description of the area the player controlled by the client is in + * at the end of the turn. */ + var areaDescription: String = "" + + /** Directions the player controlled by the client can go to. */ + var possibleDirections: Array[String] = Array.empty + + /** Items the player controlled by the client can see. */ + var visibleItems: Array[String] = Array.empty + + /** Entities the player controlled by the client can see. */ + var visibleEntities: Array[String] = Array.empty + + override def toString: String = + 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"$areaDescription\n$directionDesc\n" + + s"\n$itemDesc\n$entityDesc") + +end Turn diff --git a/src/main/scala/Server/Client.scala b/src/main/scala/Server/Client.scala index cd557c6..323b78c 100644 --- a/src/main/scala/Server/Client.scala +++ b/src/main/scala/Server/Client.scala @@ -89,7 +89,7 @@ class Client(val socket: Socket): * @param data data to buffer for sending */ private def addDataToSend(data: String): Unit = - this.outData += s"$data\r\n" + this.outData += s"$data" /** Returns one line of data if there are any line breaks. @@ -109,6 +109,7 @@ class Client(val socket: Socket): /** Makes the client play its turn */ def act(): Unit = + this.addDataToSend(ACTION_BLOCKING_INDICATOR.toString) this.nextAction.foreach(this.executeAction(_)) this.nextAction = None @@ -134,11 +135,11 @@ class Client(val socket: Socket): this.protocolIsIntact = this.protocolState match case WaitingForVersion => if line == GAME_VERSION then - addDataToSend(PROTOCOL_VERSION_GOOD) + addDataToSend(s"$PROTOCOL_VERSION_GOOD\r\n") this.protocolState = WaitingForClientName true else - addDataToSend(PROTOCOL_VERSION_BAD) + addDataToSend(s"$PROTOCOL_VERSION_BAD\r\n") false case WaitingForClientName => this.name = Some(line) @@ -158,13 +159,14 @@ class Client(val socket: Socket): ) then this.nextAction = Some(action) else if this.nextAction.isEmpty then - executeAction(action) + this.addDataToSend(ACTION_NONBLOCKING_INDICATOR.toString) + this.executeAction(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") + case Some(s) => this.addDataToSend(s"$s\r\n") + case None => this.addDataToSend("You can't do that\r\n") end Client diff --git a/src/main/scala/Server/constants.scala b/src/main/scala/Server/constants.scala index a9e4502..083db4e 100644 --- a/src/main/scala/Server/constants.scala +++ b/src/main/scala/Server/constants.scala @@ -6,6 +6,8 @@ val CRLF: Vector[Byte] = Vector(13.toByte, 10.toByte) val POLL_INTERVAL = 100 // millisec. val GAME_VERSION = "0.1.0" val TURN_INDICATOR = ">" +val ACTION_BLOCKING_INDICATOR='.' +val ACTION_NONBLOCKING_INDICATOR='+' val LIST_SEPARATOR=";" diff --git a/src/main/scala/utils.scala b/src/main/scala/utils.scala index f230be7..cfca568 100644 --- a/src/main/scala/utils.scala +++ b/src/main/scala/utils.scala @@ -1,5 +1,7 @@ package o1game.utils +import java.io.InputStream + /** Converts this string to an array of bytes (probably for transmission). * * @param str the string to convert @@ -7,3 +9,20 @@ package o1game.utils */ def stringToByteArray(str: String): Array[Byte] = str.toVector.map(_.toByte).toArray + +/** Reads n characters from the given InputStream blockingly. + * + * @param input the InputStream to read from + * @param n the number of bytes to read + * @return The read result, or None in case of failure + */ +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)) |