aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJoel Kronqvist <joel.kronqvist@iki.fi>2024-11-09 16:32:09 +0200
committerJoel Kronqvist <joel.kronqvist@iki.fi>2024-11-09 16:32:09 +0200
commit129b49a3876ceb68f271c311d5e45efb2e205300 (patch)
tree246a47a5990167c73b95fc6e02452de7f6aca6b3
parent239571e3408a3187953bef1dd5d516461bad0e31 (diff)
downloadscalevalapokalypsi-129b49a3876ceb68f271c311d5e45efb2e205300.tar.gz
scalevalapokalypsi-129b49a3876ceb68f271c311d5e45efb2e205300.zip
Made & implemented clearer protocol, added client functionality
-rw-r--r--protocol.txt15
-rw-r--r--src/main/scala/Client/Client.scala210
-rw-r--r--src/main/scala/Model/Area.scala4
-rw-r--r--src/main/scala/Server/Client.scala19
-rw-r--r--src/main/scala/Server/Server.scala56
-rw-r--r--src/main/scala/Server/constants.scala10
-rw-r--r--src/main/scala/main.scala22
-rw-r--r--src/main/scala/utils.scala9
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