diff options
author | Joel Kronqvist <joel.kronqvist@iki.fi> | 2024-11-04 21:38:50 +0200 |
---|---|---|
committer | Joel Kronqvist <joel.kronqvist@iki.fi> | 2024-11-04 22:03:40 +0200 |
commit | ae82027a9bd4e75582f9499d4006b18c29a4129c (patch) | |
tree | 92d23012158647d31fbdc0e6cdae7854ee773cbc | |
download | scalevalapokalypsi-ae82027a9bd4e75582f9499d4006b18c29a4129c.tar.gz scalevalapokalypsi-ae82027a9bd4e75582f9499d4006b18c29a4129c.zip |
Added basic TCP networking for the server.
The client's networking is still very experimental and
the actual protocol is not yet specified for either side.
-rw-r--r-- | .gitignore | 7 | ||||
-rw-r--r-- | src/main/scala/Client/Client.scala | 28 | ||||
-rw-r--r-- | src/main/scala/Server/Character.scala | 7 | ||||
-rw-r--r-- | src/main/scala/Server/Client.scala | 80 | ||||
-rw-r--r-- | src/main/scala/Server/Server.scala | 90 | ||||
-rw-r--r-- | src/main/scala/Server/constants.scala | 6 | ||||
-rw-r--r-- | src/main/scala/main.scala | 15 |
7 files changed, 233 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a114ced --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.bsp +.idea +build.sbt +project +target +src/main/scala/.bsp +src/main/scala/.scala-build diff --git a/src/main/scala/Client/Client.scala b/src/main/scala/Client/Client.scala new file mode 100644 index 0000000..1b843ab --- /dev/null +++ b/src/main/scala/Client/Client.scala @@ -0,0 +1,28 @@ +package o1game.Client + +import java.lang.Thread.sleep +import java.net.Socket +import scala.io.Source +import scala.sys.process.stdout +import o1game.constants.* + +class Client(ip: String, port: Int): + private val socket = Socket(ip, port) + private val input = socket.getInputStream + private val output = socket.getOutputStream + private val buffer: Array[Byte] = Array.ofDim(MAX_MSG_SIZE) + private var bufferIndex = 0 + + def startClient(): Unit = + 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() + + 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 diff --git a/src/main/scala/Server/Character.scala b/src/main/scala/Server/Character.scala new file mode 100644 index 0000000..082f152 --- /dev/null +++ b/src/main/scala/Server/Character.scala @@ -0,0 +1,7 @@ +package o1game.Server + +import scala.collection.mutable.Buffer + +class GameCharacter(val name: String, initialItems: Iterable[String]): + private val items: Buffer[String] = Buffer.from(items) // TODO: Item class + // TODO: A lot of other things too diff --git a/src/main/scala/Server/Client.scala b/src/main/scala/Server/Client.scala new file mode 100644 index 0000000..c7ff075 --- /dev/null +++ b/src/main/scala/Server/Client.scala @@ -0,0 +1,80 @@ +package o1game.Server + +import java.net.Socket +import scala.math.min +import o1game.constants.* + +object Client: + def parseClient(data: String, socket: Socket): Client = + Client(socket, Some(GameCharacter(data, Vector()))) + +class Client(val socket: Socket, val character: Option[GameCharacter]): + private var incompleteMessage: Array[Byte] = + Array.fill(MAX_MSG_SIZE)(0.toByte) + private var incompleteMessageIndex = 0 + + /** Calculates the amount of bytes available for future incoming messages + */ + def spaceAvailable: Int = MAX_MSG_SIZE - incompleteMessageIndex + + /** 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 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) + // TODO: the conversion may probably be exploited to crash the server + Some(String(message)) + else + None + + /** Causes the client to take the actions it has received + */ + def executeActions(): Unit = + LazyList.continually(this.nextLine()) + .takeWhile(_.isDefined) + .flatten + .foreach(s => println(s"`$this` executing `$s`")) + + +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 + + def allClients: Iterable[Client] = clients.toVector.flatten + def foreach(f: Client => Any): Unit = this.clients.flatten.foreach(f) + 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 =>
\ No newline at end of file diff --git a/src/main/scala/Server/Server.scala b/src/main/scala/Server/Server.scala new file mode 100644 index 0000000..829010c --- /dev/null +++ b/src/main/scala/Server/Server.scala @@ -0,0 +1,90 @@ +package o1game.Server + + +// TODO: TLS/SSL / import javax.net.ssl.SSLServerSocketFactory + +import java.io.IOException +import java.lang.Thread.sleep +import java.net.{ServerSocket, Socket} +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} +import o1game.constants.* + +/** `Server` exists to initialize a server for the game + * and run it with its method `startServer`. + */ +class Server(port: Int, maxClients: Int): + 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 + + /** Starts the server. Won't terminate under normal circumstances. */ + def startServer(): Unit = + while true do + sleep(POLL_INTERVAL) + this.receiveNewClient() + this.writeToAll("Test message.") + this.readFromAll() + clients.foreach(_.executeActions()) + + /** Receives a new client and stores it in `clients`. + * + * @return describes if a client was added + */ + private def receiveNewClient(): Boolean = + clientGetter.newClient() match + case Some(c) => + clients.addClient(Client(c, None)) + true + case None => + false + + /** Sends `message` to all clients + * + * @param message the message to send + */ + private def writeToAll(message: String): Unit = + clients.removeNonSatisfying((c: Client) => + try + val output = c.socket.getOutputStream + output.write(message.toVector.map(_.toByte).toArray) + output.flush() + true + catch + case e: IOException => false + ) + + /** Reads data sent by clients and stores it in the `Client`s of `clients` */ + private def readFromAll(): Unit = + clients.removeNonSatisfying((c: Client) => + try + 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) + true + catch + case e: IOException => false + ) + +end Server + +class ConnectionGetter(val socket: ServerSocket): + + private var nextClient: Future[Socket] = Future.failed(IOException()) + + 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/main/scala/Server/constants.scala b/src/main/scala/Server/constants.scala new file mode 100644 index 0000000..4260a60 --- /dev/null +++ b/src/main/scala/Server/constants.scala @@ -0,0 +1,6 @@ + +package o1game.constants + +val MAX_MSG_SIZE = 1024 // bytes +val LF: Byte = 10 +val POLL_INTERVAL = 100 // millisec. diff --git a/src/main/scala/main.scala b/src/main/scala/main.scala new file mode 100644 index 0000000..18172e2 --- /dev/null +++ b/src/main/scala/main.scala @@ -0,0 +1,15 @@ + +import o1game.Client.Client +import o1game.Server.Server + +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).startServer() + case _ => println("Invalid input") + + |