From 4de67b497e0e229fe4a42f66f833640b6e50fd5a Mon Sep 17 00:00:00 2001 From: Joel Kronqvist Date: Sun, 17 Nov 2024 13:45:44 +0200 Subject: Moved the project to an IDEA project & wrote part of README.txt --- .gitignore | 8 +- .idea/.gitignore | 3 + .idea/codeStyles/Project.xml | 7 + .idea/codeStyles/codeStyleConfig.xml | 5 + .idea/inspectionProfiles/Project_Default.xml | 93 ++++++++++ .idea/misc.xml | 6 + .idea/modules.xml | 8 + .idea/scala_settings.xml | 6 + .idea/vcs.xml | 6 + README.txt | 83 +++++++++ protocol.txt | 16 -- scalevalapokalypsi.iml | 12 ++ src/main/scala/Client/Client.scala | 178 ------------------- src/main/scala/Client/ReceivedLineParser.scala | 27 --- src/main/scala/Client/StdinLineReader.scala | 31 ---- src/main/scala/Client/Turn.scala | 32 ---- src/main/scala/Model/Action.scala | 69 -------- src/main/scala/Model/Adventure.scala | 79 --------- src/main/scala/Model/Area.scala | 117 ------------- src/main/scala/Model/Entity.scala | 103 ----------- src/main/scala/Model/Item.scala | 20 --- src/main/scala/Server/Client.scala | 173 ------------------ src/main/scala/Server/Clients.scala | 82 --------- src/main/scala/Server/ConnectionGetter.scala | 25 --- src/main/scala/Server/Server.scala | 195 --------------------- src/main/scala/Server/constants.scala | 19 -- src/main/scala/main.scala | 26 --- src/main/scala/utils.scala | 28 --- src/scalevalapokalypsi/Client/Client.scala | 178 +++++++++++++++++++ .../Client/ReceivedLineParser.scala | 27 +++ .../Client/StdinLineReader.scala | 31 ++++ src/scalevalapokalypsi/Client/Turn.scala | 32 ++++ src/scalevalapokalypsi/Model/Action.scala | 69 ++++++++ src/scalevalapokalypsi/Model/Adventure.scala | 79 +++++++++ src/scalevalapokalypsi/Model/Area.scala | 117 +++++++++++++ src/scalevalapokalypsi/Model/Entity.scala | 103 +++++++++++ src/scalevalapokalypsi/Model/Item.scala | 20 +++ src/scalevalapokalypsi/Server/Client.scala | 173 ++++++++++++++++++ src/scalevalapokalypsi/Server/Clients.scala | 82 +++++++++ .../Server/ConnectionGetter.scala | 25 +++ src/scalevalapokalypsi/Server/Server.scala | 195 +++++++++++++++++++++ src/scalevalapokalypsi/constants/constants.scala | 19 ++ src/scalevalapokalypsi/main.scala | 27 +++ src/scalevalapokalypsi/utils/utils.scala | 28 +++ tyylimaare.txt | 157 ----------------- 45 files changed, 1435 insertions(+), 1384 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/scala_settings.xml create mode 100644 .idea/vcs.xml create mode 100644 README.txt delete mode 100644 protocol.txt create mode 100644 scalevalapokalypsi.iml delete mode 100644 src/main/scala/Client/Client.scala delete mode 100644 src/main/scala/Client/ReceivedLineParser.scala delete mode 100644 src/main/scala/Client/StdinLineReader.scala delete mode 100644 src/main/scala/Client/Turn.scala delete mode 100644 src/main/scala/Model/Action.scala delete mode 100644 src/main/scala/Model/Adventure.scala delete mode 100644 src/main/scala/Model/Area.scala delete mode 100644 src/main/scala/Model/Entity.scala delete mode 100644 src/main/scala/Model/Item.scala delete mode 100644 src/main/scala/Server/Client.scala delete mode 100644 src/main/scala/Server/Clients.scala delete mode 100644 src/main/scala/Server/ConnectionGetter.scala delete mode 100644 src/main/scala/Server/Server.scala delete mode 100644 src/main/scala/Server/constants.scala delete mode 100644 src/main/scala/main.scala delete mode 100644 src/main/scala/utils.scala create mode 100644 src/scalevalapokalypsi/Client/Client.scala create mode 100644 src/scalevalapokalypsi/Client/ReceivedLineParser.scala create mode 100644 src/scalevalapokalypsi/Client/StdinLineReader.scala create mode 100644 src/scalevalapokalypsi/Client/Turn.scala create mode 100644 src/scalevalapokalypsi/Model/Action.scala create mode 100644 src/scalevalapokalypsi/Model/Adventure.scala create mode 100644 src/scalevalapokalypsi/Model/Area.scala create mode 100644 src/scalevalapokalypsi/Model/Entity.scala create mode 100644 src/scalevalapokalypsi/Model/Item.scala create mode 100644 src/scalevalapokalypsi/Server/Client.scala create mode 100644 src/scalevalapokalypsi/Server/Clients.scala create mode 100644 src/scalevalapokalypsi/Server/ConnectionGetter.scala create mode 100644 src/scalevalapokalypsi/Server/Server.scala create mode 100644 src/scalevalapokalypsi/constants/constants.scala create mode 100644 src/scalevalapokalypsi/main.scala create mode 100644 src/scalevalapokalypsi/utils/utils.scala delete mode 100644 tyylimaare.txt diff --git a/.gitignore b/.gitignore index a114ced..1fcb152 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1 @@ -.bsp -.idea -build.sbt -project -target -src/main/scala/.bsp -src/main/scala/.scala-build +out diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..919ce1f --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..a55e7a1 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..0f76f37 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,93 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..e6be3f1 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..8a7c304 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/scala_settings.xml b/.idea/scala_settings.xml new file mode 100644 index 0000000..4608fe0 --- /dev/null +++ b/.idea/scala_settings.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..5f4f277 --- /dev/null +++ b/README.txt @@ -0,0 +1,83 @@ + +[PELIN NIMI TÄHÄN] +================== + +[pelin nimi] on tekstipeli, joka jäljittelee hieman Infocomin tekstipelejä ja +Z-koneen tuntumaa. Koska Z-koneen uudelleen keksiminen olisi tylsää, toteuttaa +[pelin nimi] ominaisuuksia, joita Z-koneeseen olisi ollut hankala toteuttaa. +Tällä on toki hintansa — seikkailujen lukeminen tekstitiedostoista olisi +tämän pelin Z-koneen ylittävien ominaisuuksien vuoksi hankalaa. + +Ensimmäinen olennainen lisäys on moninpelipainotteisuus. Tämä olisi todennnä- +köisesti ollut toteutettavissa Z-koneeseenkin, esim: +=> https://github.com/icculus/mojozork + +Toinen olennainen lisäys on suomalainen kulttuuriperintö, aikapaineella höys- +tettynä: pelissä lauletaan vihamiehiä suohon, ja se tapahtuu kirjoitusnopeus- +testien merkeissä! + +Z-koneen kannalta haastavin lisäys (joskaan ei muutoin tämän pelin teknisesti +haastavin osuus) lienee monimutkaisempi ei-pelattavien henkilöhahmojen +tekoäly. Kun tekoälyt kirjoitetaan suoraan scala-ohjelmaan funktioina, voivat +ne olennaisesti tehdä mitä tahansa, sen sijaan että Z-kone millään lailla +rajoittaisi niitä. + + +Pelin käynnistäminen +-------------------- + +[yksittäisen käyttäjän käynnistäminen] + +Moninpeliominaisuudesta saat eniten iloa irti, joten jaa [pelin nimi] +ystävillesi ja ala pelaamaan! Jos sinulla ei ole ystäviä, voit myös kokea +moninpeliominaisuuden mahtavuuden hieman vaisumpana käynnistämällä toisen +käyttäjän ylläolevien ohjeiden mukaisesti ja liittymällä samaan peliin +(eli samalle serverille), johon edellisellä käyttäjällä liityit. Toista +tämä niin monta kertaa kuin tahdot, kunnes serveri valittaa liian suuresta +pelaajamäärästä. + + +Pelin tavoite +------------- + +[lisää pelille tavoite] + + +Pelin pelaaminen +---------------- + +[pelin nimeä] ohjataan kutakuinkin samalla lailla, kuin Z-koneen pelejä. +On kuitenkin hyvä huomata, että peliä ohjaava serveri usein pakottaa vuorot +tiettyyn aikaraamiin muiden pelaajien odottelun vähentämiseksi. Tämä aikaraja +näkyy aina syötteen (merkitään '>'-merkillä) edessä: +``` +Istut metsässä ja pohdit maailman menoa. Polkuja johtaa kaikkiin ilmansuuntiin. +[30]> +``` +Yllä aikaraja olisi 30 sekuntia. + +Myöskään kaikki komennot eivät ole samoja kuin Z-koneessa, eikä se ollut +tavoitekaan. Alla on lista komennoista: + +`mene [suunta]` + Käskee pelaajan liikkua annettuun suuntaan. + +`lepää` + Käskee pelaajan levätä, eli odottaa vuoron päättymistä. + +`sano [viisaus]` + Sanoo annetun enemmän tai vähemmän viisaan lausahduksen ääneen. + +`sano [terveiset] henkilölle [äiti]` + Sanoo annetut terveiset annetulle pelin sisäiselle henkilölle. + Pelin tekijöitä ei kuulu pitää vastuussa, jos tätä kautta yritetään + välittää terveisiä pelin ulkopuolisille henkilöille. + +`laula [olento] suohon` + Aloittaa annetun olennon suohonlaulannan. Peli tulee seuraavaksi pyytämään + sinua kirjoittamaan jonkin säkeen, todennäköisesti kalevanlamitassa. + Kirjoita tämä säe niin nopeasti kuin pystyt, koska + laulun vaikutus määräytyy kirjoitusnopeuden + perusteella! + +[lisää loput käskyt] diff --git a/protocol.txt b/protocol.txt deleted file mode 100644 index bbeae64..0000000 --- a/protocol.txt +++ /dev/null @@ -1,16 +0,0 @@ -Client: [version number]CRLF[client name|] -Server: [good/version old] -... -Server: [time limit in int/secs]CRLF # signifies game start - [instantly gives turn info] - -Before turn: -N x [Action blocker indicator][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/scalevalapokalypsi.iml b/scalevalapokalypsi.iml new file mode 100644 index 0000000..a76f8e7 --- /dev/null +++ b/scalevalapokalypsi.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/scala/Client/Client.scala b/src/main/scala/Client/Client.scala deleted file mode 100644 index fc3e6b8..0000000 --- a/src/main/scala/Client/Client.scala +++ /dev/null @@ -1,178 +0,0 @@ -package o1game.Client - -import java.lang.Thread.sleep -import java.net.Socket -import scala.io.Source -import scala.sys.process.stdout -import o1game.constants.* -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 java.lang.System.currentTimeMillis - - -/** A helper enum for `Client` to keep track of communications with the server - */ -enum ServerLineState: - case WaitingForTimeLimit, - ActionDescription, - TurnIndicator, - AreaDescription, - Directions, - Items, - Entities - - -/** 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 - - - -/** 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 val serverLineParser = ReceivedLineParser() - private val stdinReader = StdinLineReader() - - private var serverLineState = ServerLineState.WaitingForTimeLimit - - /** 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() - - - /** Starts the client. This shouldn't terminate. */ - def startClient(): Unit = - - stdinReader.startReading() - - while true do - sleep(POLL_INTERVAL) - - this.readAndParseDataFromServer() - - if this.lastExecutedTurn < this.lastTurnStart then - print(this.giveTurn()) - - stdinReader.newLine().foreach((s: String) => - output.write(stringToByteArray(s+"\r\n")) - ) - - 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(s"$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) - 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.WaitingForTimeLimit => - val time = line.toLongOption - time match - case Some(t) => this.timeLimit = t - case None => print("Invalid time limit, oh no!!!") - this.serverLineState = ServerLineState.TurnIndicator - this.lastTurnStart = currentTimeMillis / 1000 - - case ServerLineState.ActionDescription => - if line.nonEmpty && line.head == ACTION_BLOCKING_INDICATOR then - this.canAct = false - this.displayAction(line.tail) - - case ServerLineState.TurnIndicator => - this.serverLineState = ServerLineState.AreaDescription - - case ServerLineState.AreaDescription => - this.turnInfo.areaDescription = line - this.serverLineState = ServerLineState.Directions - - case ServerLineState.Directions => - this.turnInfo.possibleDirections = line.split(LIST_SEPARATOR) - this.serverLineState = ServerLineState.Items // TODO: maybe use a list instead? - - case ServerLineState.Items => - this.turnInfo.visibleItems = line.split(LIST_SEPARATOR) - this.serverLineState = ServerLineState.Entities - - case ServerLineState.Entities => - this.turnInfo.visibleEntities = line.split(LIST_SEPARATOR) - this.serverLineState = ServerLineState.ActionDescription - this.lastTurnStart = currentTimeMillis() / 1000 - - end parseLineFromServer - -end Client diff --git a/src/main/scala/Client/ReceivedLineParser.scala b/src/main/scala/Client/ReceivedLineParser.scala deleted file mode 100644 index 7cbf935..0000000 --- a/src/main/scala/Client/ReceivedLineParser.scala +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 42a1f40..0000000 --- a/src/main/scala/Client/StdinLineReader.scala +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 6b78811..0000000 --- a/src/main/scala/Client/Turn.scala +++ /dev/null @@ -1,32 +0,0 @@ -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/Model/Action.scala b/src/main/scala/Model/Action.scala deleted file mode 100644 index 55f7f27..0000000 --- a/src/main/scala/Model/Action.scala +++ /dev/null @@ -1,69 +0,0 @@ -package o1game.Model - -/** The class `Action` represents actions that a player may take in a text adventure game. - * `Action` objects are constructed on the basis of textual commands and are, in effect, - * parsers for such commands. An action object is immutable after creation. - * @param input a textual in-game command such as “go east” or “rest” */ -class Action(input: String): - - private val commandText = input.trim.toLowerCase - private val verb = commandText.takeWhile( _ != ' ' ) - private val modifiers = commandText.drop(verb.length).trim - - def takesATurnFor(actor: Player): Boolean = - this.verb match - case "rest" => true - case "go" => actor.location.hasNeighbor(modifiers) - case "get" => actor.location.hasItem(this.modifiers) - case "drop" => actor.canDrop(this.modifiers) - case "say" => false - case other => false - - /** Causes the given player to take the action represented by this object, assuming - * that the command was understood. Returns a description of what happened as a result - * of the action (such as “You go west.”). The description is returned in an `Option` - * wrapper; if the command was not recognized, `None` is returned. */ - def execute(actor: Player): Option[String] = - val oldLocation = actor.location - val resOption: Option[(String, String)] = this.verb match - case "go" => Some(actor.go(this.modifiers)) - case "rest" => Some(actor.rest()) - case "get" => Some(actor.pickUp(this.modifiers)) - case "say" => - val to = "to" - val recipient = modifiers.reverse.takeWhile(_ != ' ').reverse - val recipientEntity = actor.location.getEntity(recipient) - val maybeTo = modifiers.slice( - modifiers.length - recipient.length - s"$to ".length, - modifiers.length - recipient.length - 1 - ) - val message = - modifiers.take(modifiers.length - recipient.length - 4) - if maybeTo == to then - recipientEntity.map(actor.sayTo(_, message)) - else - Some(actor.say(modifiers)) - case "drop" => Some(actor.drop(this.modifiers)) - case "xyzzy" => Some(( - "The grue tastes yummy.", - s"${actor.name} tastes some grue.") - ) - case other => None - -// println(resOption) -// println(actor.location.getEntities) - resOption.map(_(1)).filter(_.length > 0) - .foreach(s => - actor.location.getEntities.filter(_ != actor).foreach(_.observe(s)) - if oldLocation != actor.location then - oldLocation.getEntities.foreach(_.observe(s)) - ) - - resOption.map(_(0)) - - - /** Returns a textual description of the action object, for debugging purposes. */ - override def toString = s"$verb (modifiers: $modifiers)" - -end Action - diff --git a/src/main/scala/Model/Adventure.scala b/src/main/scala/Model/Adventure.scala deleted file mode 100644 index dfcb100..0000000 --- a/src/main/scala/Model/Adventure.scala +++ /dev/null @@ -1,79 +0,0 @@ -package o1game.Model - -import scala.collection.mutable.Map - -/** The class `Adventure` represents text adventure games. An adventure consists of a player and - * a number of areas that make up the game world. It provides methods for playing the game one - * turn at a time and for checking the state of the game. - * - * N.B. This version of the class has a lot of “hard-coded” information that pertains to a very - * specific adventure game that involves a small trip through a twisted forest. All newly created - * instances of class `Adventure` are identical to each other. To create other kinds of adventure - * games, you will need to modify or replace the source code of this class. */ -class Adventure(val playerNames: Vector[String]): - - private val middle = Area("Forest", "You are somewhere in the forest. There are a lot of trees here.\nBirds are singing.") - private val northForest = Area("Forest", "You are somewhere in the forest. A tangle of bushes blocks further passage north.\nBirds are singing.") - private val southForest = Area("Forest", "The forest just goes on and on.") - private val clearing = Area("Forest Clearing", "You are at a small clearing in the middle of forest.\nNearly invisible, twisted paths lead in many directions.") - private val tangle = Area("Tangle of Bushes", "You are in a dense tangle of bushes. It's hard to see exactly where you're going.") - private val home = Area("Home", "Home sweet home! Now the only thing you need is a working remote control.") - private val destination = home - - middle.setNeighbors(Vector("north" -> northForest, "east" -> tangle, "south" -> southForest, "west" -> clearing)) - northForest.setNeighbors(Vector("east" -> tangle, "south" -> middle, "west" -> clearing)) - southForest.setNeighbors(Vector("north" -> middle, "east" -> tangle, "south" -> southForest, "west" -> clearing)) - clearing.setNeighbors(Vector("north" -> northForest, "east" -> middle, "south" -> southForest, "west" -> northForest)) - tangle.setNeighbors(Vector("north" -> northForest, "east" -> home, "south" -> southForest, "west" -> northForest)) - home.setNeighbors(Vector("west" -> tangle)) - - clearing.addItem(Item("battery", "It's a small battery cell. Looks new.")) - southForest.addItem(Item( - "remote", - "It's the remote control for your TV.\n" + - "What it was doing in the forest, you have no idea.\n" + - "Problem is, there's no battery." - )) - - val players: Map[String, Player] = Map() - playerNames.foreach(this.addPlayer(_)) - - val entities: Map[String, Entity] = Map() - private val gruu = Entity("Gruu", northForest) - northForest.addEntity(gruu) - this.entities += gruu.name -> gruu - - /** Adds a player entity with the specified name to the game. - * - * @param name the name of the player entity to add - * @return the created player entity - */ - def addPlayer(name: String): Player = - val newPlayer = Player(name, middle) - middle.addEntity(newPlayer) - players += name -> newPlayer - newPlayer - - /** Gets the player entity with the specified name. - * - * @param name name of the player to find - * @return the player, if one with the name was found - */ - def getPlayer(name: String): Option[Player] = this.players.get(name) - - def getEntity[A >: Entity](name: String) = - this.players.getOrElse(name, this.entities.get(name)) - - /** Returns a message that is to be displayed to the player at the beginning of the game. */ - def welcomeMessage = "Generic welcome message" - - /** Plays a turn by executing the given in-game command, such as “go west”. Returns a textual - * report of what happened, or an error message if the command was unknown. In the latter - * case, no turns elapse. */ - def playTurnOfPlayer(playerName: String, command: String): Option[String] = - val action = Action(command) - val actor = this.players.get(playerName) - actor.flatMap(action.execute(_)) - -end Adventure - diff --git a/src/main/scala/Model/Area.scala b/src/main/scala/Model/Area.scala deleted file mode 100644 index 6721957..0000000 --- a/src/main/scala/Model/Area.scala +++ /dev/null @@ -1,117 +0,0 @@ -package o1game.Model - -import scala.collection.mutable.Map - -/** The class `Area` represents locations in a text adventure game world. A game world - * consists of areas. In general, an “area” can be pretty much anything: a room, a building, - * an acre of forest, or something completely different. What different areas have in - * common is that players can be located in them and that they can have exits leading to - * other, neighboring areas. An area also has a name and a description. - * @param name the name of the area - * @param description a basic description of the area (typically not including information about items) */ -class Area(val name: String, var description: String): - - private val neighbors = Map[String, Area]() - private val items: Map[String, Item] = Map() - private val entities: Map[String, Entity] = Map() - - /** Returns the area that can be reached from this area by moving in the given direction. The result - * is returned in an `Option`; `None` is returned if there is no exit in the given direction. */ - 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.values.map(_.name) - def getEntity(name: String): Option[Entity] = this.entities.get(name) - def getEntities: Iterable[Entity] = this.entities.values - - /** Tells whether this area has a neighbor in the given direction. - * - * @param direction the direction to check - * @return whether there is a neighbor in the direction - */ - def hasNeighbor(direction: String): Boolean = - this.neighbors.contains(direction) - - /** Adds an exit from this area to the given area. The neighboring area is reached by moving in - * the specified direction from this area. */ - def setNeighbor(direction: String, neighbor: Area) = - this.neighbors += direction -> neighbor - - /** Adds exits from this area to the given areas. Calling this method is equivalent to calling - * the `setNeighbor` method on each of the given direction–area pairs. - * @param exits contains pairs consisting of a direction and the neighboring area in that direction - * @see [[setNeighbor]] */ - def setNeighbors(exits: Vector[(String, Area)]) = - this.neighbors ++= exits - - /** Adds the specified item - * - * @param item the item to add - */ - def addItem(item: Item): Unit = this.items += item.name -> item - - /** Adds multiple items - * - * @param items a once iterable collection of items to add - */ - def addItems(items: IterableOnce[Item]) = - items.iterator.foreach(i => this.items += i.name -> i) - - def hasItem(itemName: String) = this.items.contains(itemName) - - - /** Removes the specified item if it exists. - * - * @param itemName the name of the item to remove - * @return an option containing the removed item - */ - def removeItem(itemName: String): Option[Item] = - this.items.remove(itemName) - - /** Adds the specified entity to the area. - * - * @param entity the entity to add. - */ - def addEntity(entity: Entity): Unit = - this.entities += entity.name.toLowerCase -> entity - - /** Removes the entity with the name `entityName`. - * - * @param entityName the name of the entity to remove - * @return an option containing the removed entity if it was in the area - */ - def removeEntity(entityName: String): Option[Entity] = - this.entities.remove(entityName.toLowerCase()) - - /** Returns a multi-line description of the area as a player sees it. This includes a basic - * description of the area as well as information about exits and items. If there are no - * items present, the return value has the form "DESCRIPTION\n\nExits available: - * DIRECTIONS SEPARATED BY SPACES". If there are one or more items present, the return - * value has the form "DESCRIPTION\nYou see here: ITEMS SEPARATED BY SPACES\n\nExits available: - * DIRECTIONS SEPARATED BY SPACES". The items and directions are listed in an arbitrary order. */ - def fullDescription: String = - val exitList = this.neighbors.keys.mkString(" ") - val itemList = this.items.keys.mkString(" ") - val entityList = this.getEntityNames.mkString(" ") - val itemDescription = - if this.items.nonEmpty then - s"\nYou see here: ${itemList}" - else "" - val entityDescription = - if this.entities.nonEmpty then - s"\nThere are entities: ${entityList}" - else "" - (this.description + - itemDescription + - entityDescription + - s"\n\nExits available: $exitList") - - - /** Returns a single-line description of the area for debugging purposes. */ - override def toString = - this.name + ": " + this.description.replaceAll("\n", " ").take(150) - -end Area - diff --git a/src/main/scala/Model/Entity.scala b/src/main/scala/Model/Entity.scala deleted file mode 100644 index d8e8559..0000000 --- a/src/main/scala/Model/Entity.scala +++ /dev/null @@ -1,103 +0,0 @@ -package o1game.Model - -import scala.collection.mutable.{Buffer,Map} - - - -/** A `Player` object represents a player character controlled by one real-life player - * of the program. - * - * A player object’s state is mutable: the player’s location and possessions can change, - * for instance. - * - * @param startingArea the player’s initial location */ -class Player(name: String, initialLocation: Area) extends Entity(name, initialLocation): - - private val observations: Buffer[String] = Buffer.empty - - override def observe(observation: String): Unit = - this.observations.append(observation) - - def readAndClearObservations(): Vector[String] = - val res = this.observations.toVector - observations.clear() - res - -end Player - -/** An in-game entity. - * - * @param name the name of the entity - * @param initialLocation the Area where the entity is instantiated - */ -class Entity(val name: String, initialLocation: Area): - private var currentLocation: Area = initialLocation - private var quitCommandGiven = false // one-way flag - private val inventory: Map[String, Item] = Map() - - /** Determines if the player has indicated a desire to quit the game. */ - def hasQuit = this.quitCommandGiven // TODO: This is probably unneccessary? - - /** Does nothing, except possibly in inherited classes. */ - def observe(observation: String): Unit = - println("no observation made.") - () - - /** Returns the player’s current location. */ - def location = this.currentLocation - - /** Attempts to move the player in the given direction. This is successful if there - * is an exit from the player’s current location towards the direction name. Returns - * a description of the result: "You go DIRECTION." or "You can't go DIRECTION." */ - def go(direction: String): (String, String) = - val destination = this.location.neighbor(direction) - if destination.isDefined then - val removeSuccess = this.currentLocation.removeEntity(this.name) - assert(removeSuccess.isDefined) // Production - assertions off - this.currentLocation = destination.getOrElse(this.currentLocation) - destination.foreach(_.addEntity(this)) - (s"You go $direction.", s"$name goes $direction") - else - ( - s"You can't go $direction.", - s"$name tries to go $direction and stumbles in their feet." - ) - - def pickUp(itemName: String): (String, String) = - this.currentLocation.removeItem(itemName) match - case Some(i) => - this.inventory += i.name -> i - (s"You pick up the ${i.name}", s"$name picks up the ${i.name}") - case None => (s"There is no $itemName here to pick up.", "WHAAAT THIS SHOULDN'T HAPPEN???") - - def drop(itemName: String): (String, String) = - this.inventory.remove(itemName) match - case Some(item) => - this.currentLocation.addItem(item) - (s"You drop the $itemName", s"$name drops the $itemName") - case None => ("You don't have that!", s"$name reaches their backpack to drop $itemName but miserably fails to find it there.") - - def sayTo(entity: Entity, message: String): (String, String) = - entity.observe(s"Alice: \"$message\"") - (s"You say so to ${entity.name}.", "") - - def say(message: String): (String, String) = - ("You say that aloud.", s"$name: \"$message\"") - - /** Tells whether this entity can drop the specified item - * (if an action were to specify so). - * - * @param itemName the name to check - * @return whether this entity has this item and can drop it - */ - def canDrop(itemName: String): Boolean = this.inventory.contains(itemName) - - /** Causes the player to rest for a short while (this has no substantial effect in game terms). - * Returns a description of what happened. */ - def rest(): (String, String) = - ("You rest for a while. Better get a move on, though.", "") - - /** Returns a brief description of the player’s state, for debugging purposes. */ - override def toString = "Now at: " + this.location.name - -end Entity diff --git a/src/main/scala/Model/Item.scala b/src/main/scala/Model/Item.scala deleted file mode 100644 index 229828d..0000000 --- a/src/main/scala/Model/Item.scala +++ /dev/null @@ -1,20 +0,0 @@ -package o1game.Model - -import scala.annotation.targetName - -/** The class `Item` represents items in a text adventure game. Each item has a name - * and a longer description. (In later versions of the adventure game, items may - * have other features as well.) - * - * N.B. It is assumed, but not enforced by this class, that items have unique names. - * That is, no two items in a game world have the same name. - * - * @param name the item’s name - * @param description the item’s description */ -class Item(val name: String, val description: String): - - /** Returns a short textual representation of the item (its name, that is). */ - override def toString = this.name - -end Item - diff --git a/src/main/scala/Server/Client.scala b/src/main/scala/Server/Client.scala deleted file mode 100644 index 3cd2b36..0000000 --- a/src/main/scala/Server/Client.scala +++ /dev/null @@ -1,173 +0,0 @@ -package o1game.Server - -import java.net.Socket -import scala.math.min -import o1game.constants.* -import ServerProtocolState.* -import o1game.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/main/scala/Server/Clients.scala b/src/main/scala/Server/Clients.scala deleted file mode 100644 index 6487446..0000000 --- a/src/main/scala/Server/Clients.scala +++ /dev/null @@ -1,82 +0,0 @@ -package o1game.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/main/scala/Server/ConnectionGetter.scala b/src/main/scala/Server/ConnectionGetter.scala deleted file mode 100644 index b3246a7..0000000 --- a/src/main/scala/Server/ConnectionGetter.scala +++ /dev/null @@ -1,25 +0,0 @@ -package o1game.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/main/scala/Server/Server.scala b/src/main/scala/Server/Server.scala deleted file mode 100644 index 7864c49..0000000 --- a/src/main/scala/Server/Server.scala +++ /dev/null @@ -1,195 +0,0 @@ -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,Entity,Player} -import o1game.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 diff --git a/src/main/scala/Server/constants.scala b/src/main/scala/Server/constants.scala deleted file mode 100644 index 083db4e..0000000 --- a/src/main/scala/Server/constants.scala +++ /dev/null @@ -1,19 +0,0 @@ - -package o1game.constants - -val MAX_MSG_SIZE = 1024 // bytes -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=";" - -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 deleted file mode 100644 index d68664a..0000000 --- a/src/main/scala/main.scala +++ /dev/null @@ -1,26 +0,0 @@ - -import o1game.Client.newClient -import o1game.Server.Server -import java.lang.Thread -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("How do you want to play?\n1) Host and join local game\n2) Join local game\n> ") - readLine().toIntOption match - case Some(1) => - Thread(() => Server(2267, 5, 30, true).startServer()).start() - 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 deleted file mode 100644 index cfca568..0000000 --- a/src/main/scala/utils.scala +++ /dev/null @@ -1,28 +0,0 @@ -package o1game.utils - -import java.io.InputStream - -/** 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 - -/** 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)) diff --git a/src/scalevalapokalypsi/Client/Client.scala b/src/scalevalapokalypsi/Client/Client.scala new file mode 100644 index 0000000..41b1003 --- /dev/null +++ b/src/scalevalapokalypsi/Client/Client.scala @@ -0,0 +1,178 @@ +package scalevalapokalypsi.Client + +import java.lang.Thread.sleep +import java.net.Socket +import scala.io.Source +import scala.sys.process.stdout +import scalevalapokalypsi.constants.* +import scalevalapokalypsi.utils.{stringToByteArray,getNCharsFromSocket} +import scalevalapokalypsi.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 java.lang.System.currentTimeMillis + + +/** A helper enum for `Client` to keep track of communications with the server + */ +enum ServerLineState: + case WaitingForTimeLimit, + ActionDescription, + TurnIndicator, + AreaDescription, + Directions, + Items, + Entities + + +/** 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 + + + +/** 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 val serverLineParser = ReceivedLineParser() + private val stdinReader = StdinLineReader() + + private var serverLineState = ServerLineState.WaitingForTimeLimit + + /** 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() + + + /** Starts the client. This shouldn't terminate. */ + def startClient(): Unit = + + stdinReader.startReading() + + while true do + sleep(POLL_INTERVAL) + + this.readAndParseDataFromServer() + + if this.lastExecutedTurn < this.lastTurnStart then + print(this.giveTurn()) + + stdinReader.newLine().foreach((s: String) => + output.write(stringToByteArray(s+"\r\n")) + ) + + 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(s"$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) + 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.WaitingForTimeLimit => + val time = line.toLongOption + time match + case Some(t) => this.timeLimit = t + case None => print("Invalid time limit, oh no!!!") + this.serverLineState = ServerLineState.TurnIndicator + this.lastTurnStart = currentTimeMillis / 1000 + + case ServerLineState.ActionDescription => + if line.nonEmpty && line.head == ACTION_BLOCKING_INDICATOR then + this.canAct = false + this.displayAction(line.tail) + + case ServerLineState.TurnIndicator => + this.serverLineState = ServerLineState.AreaDescription + + case ServerLineState.AreaDescription => + this.turnInfo.areaDescription = line + this.serverLineState = ServerLineState.Directions + + case ServerLineState.Directions => + this.turnInfo.possibleDirections = line.split(LIST_SEPARATOR) + this.serverLineState = ServerLineState.Items // TODO: maybe use a list instead? + + case ServerLineState.Items => + this.turnInfo.visibleItems = line.split(LIST_SEPARATOR) + this.serverLineState = ServerLineState.Entities + + case ServerLineState.Entities => + this.turnInfo.visibleEntities = line.split(LIST_SEPARATOR) + this.serverLineState = ServerLineState.ActionDescription + this.lastTurnStart = currentTimeMillis() / 1000 + + end parseLineFromServer + +end Client diff --git a/src/scalevalapokalypsi/Client/ReceivedLineParser.scala b/src/scalevalapokalypsi/Client/ReceivedLineParser.scala new file mode 100644 index 0000000..dfcc2d2 --- /dev/null +++ b/src/scalevalapokalypsi/Client/ReceivedLineParser.scala @@ -0,0 +1,27 @@ +package scalevalapokalypsi.Client + +import scala.collection.mutable.Buffer +import scalevalapokalypsi.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/scalevalapokalypsi/Client/StdinLineReader.scala b/src/scalevalapokalypsi/Client/StdinLineReader.scala new file mode 100644 index 0000000..6ba8761 --- /dev/null +++ b/src/scalevalapokalypsi/Client/StdinLineReader.scala @@ -0,0 +1,31 @@ +package scalevalapokalypsi.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/scalevalapokalypsi/Client/Turn.scala b/src/scalevalapokalypsi/Client/Turn.scala new file mode 100644 index 0000000..30101c5 --- /dev/null +++ b/src/scalevalapokalypsi/Client/Turn.scala @@ -0,0 +1,32 @@ +package scalevalapokalypsi.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/scalevalapokalypsi/Model/Action.scala b/src/scalevalapokalypsi/Model/Action.scala new file mode 100644 index 0000000..32f513d --- /dev/null +++ b/src/scalevalapokalypsi/Model/Action.scala @@ -0,0 +1,69 @@ +package scalevalapokalypsi.Model + +/** The class `Action` represents actions that a player may take in a text adventure game. + * `Action` objects are constructed on the basis of textual commands and are, in effect, + * parsers for such commands. An action object is immutable after creation. + * @param input a textual in-game command such as “go east” or “rest” */ +class Action(input: String): + + private val commandText = input.trim.toLowerCase + private val verb = commandText.takeWhile( _ != ' ' ) + private val modifiers = commandText.drop(verb.length).trim + + def takesATurnFor(actor: Player): Boolean = + this.verb match + case "rest" => true + case "go" => actor.location.hasNeighbor(modifiers) + case "get" => actor.location.hasItem(this.modifiers) + case "drop" => actor.canDrop(this.modifiers) + case "say" => false + case other => false + + /** Causes the given player to take the action represented by this object, assuming + * that the command was understood. Returns a description of what happened as a result + * of the action (such as “You go west.”). The description is returned in an `Option` + * wrapper; if the command was not recognized, `None` is returned. */ + def execute(actor: Player): Option[String] = + val oldLocation = actor.location + val resOption: Option[(String, String)] = this.verb match + case "go" => Some(actor.go(this.modifiers)) + case "rest" => Some(actor.rest()) + case "get" => Some(actor.pickUp(this.modifiers)) + case "say" => + val to = "to" + val recipient = modifiers.reverse.takeWhile(_ != ' ').reverse + val recipientEntity = actor.location.getEntity(recipient) + val maybeTo = modifiers.slice( + modifiers.length - recipient.length - s"$to ".length, + modifiers.length - recipient.length - 1 + ) + val message = + modifiers.take(modifiers.length - recipient.length - 4) + if maybeTo == to then + recipientEntity.map(actor.sayTo(_, message)) + else + Some(actor.say(modifiers)) + case "drop" => Some(actor.drop(this.modifiers)) + case "xyzzy" => Some(( + "The grue tastes yummy.", + s"${actor.name} tastes some grue.") + ) + case other => None + +// println(resOption) +// println(actor.location.getEntities) + resOption.map(_(1)).filter(_.length > 0) + .foreach(s => + actor.location.getEntities.filter(_ != actor).foreach(_.observe(s)) + if oldLocation != actor.location then + oldLocation.getEntities.foreach(_.observe(s)) + ) + + resOption.map(_(0)) + + + /** Returns a textual description of the action object, for debugging purposes. */ + override def toString = s"$verb (modifiers: $modifiers)" + +end Action + diff --git a/src/scalevalapokalypsi/Model/Adventure.scala b/src/scalevalapokalypsi/Model/Adventure.scala new file mode 100644 index 0000000..2bf6cfe --- /dev/null +++ b/src/scalevalapokalypsi/Model/Adventure.scala @@ -0,0 +1,79 @@ +package scalevalapokalypsi.Model + +import scala.collection.mutable.Map + +/** The class `Adventure` represents text adventure games. An adventure consists of a player and + * a number of areas that make up the game world. It provides methods for playing the game one + * turn at a time and for checking the state of the game. + * + * N.B. This version of the class has a lot of “hard-coded” information that pertains to a very + * specific adventure game that involves a small trip through a twisted forest. All newly created + * instances of class `Adventure` are identical to each other. To create other kinds of adventure + * games, you will need to modify or replace the source code of this class. */ +class Adventure(val playerNames: Vector[String]): + + private val middle = Area("Forest", "You are somewhere in the forest. There are a lot of trees here.\nBirds are singing.") + private val northForest = Area("Forest", "You are somewhere in the forest. A tangle of bushes blocks further passage north.\nBirds are singing.") + private val southForest = Area("Forest", "The forest just goes on and on.") + private val clearing = Area("Forest Clearing", "You are at a small clearing in the middle of forest.\nNearly invisible, twisted paths lead in many directions.") + private val tangle = Area("Tangle of Bushes", "You are in a dense tangle of bushes. It's hard to see exactly where you're going.") + private val home = Area("Home", "Home sweet home! Now the only thing you need is a working remote control.") + private val destination = home + + middle.setNeighbors(Vector("north" -> northForest, "east" -> tangle, "south" -> southForest, "west" -> clearing)) + northForest.setNeighbors(Vector("east" -> tangle, "south" -> middle, "west" -> clearing)) + southForest.setNeighbors(Vector("north" -> middle, "east" -> tangle, "south" -> southForest, "west" -> clearing)) + clearing.setNeighbors(Vector("north" -> northForest, "east" -> middle, "south" -> southForest, "west" -> northForest)) + tangle.setNeighbors(Vector("north" -> northForest, "east" -> home, "south" -> southForest, "west" -> northForest)) + home.setNeighbors(Vector("west" -> tangle)) + + clearing.addItem(Item("battery", "It's a small battery cell. Looks new.")) + southForest.addItem(Item( + "remote", + "It's the remote control for your TV.\n" + + "What it was doing in the forest, you have no idea.\n" + + "Problem is, there's no battery." + )) + + val players: Map[String, Player] = Map() + playerNames.foreach(this.addPlayer(_)) + + val entities: Map[String, Entity] = Map() + private val gruu = Entity("Gruu", northForest) + northForest.addEntity(gruu) + this.entities += gruu.name -> gruu + + /** Adds a player entity with the specified name to the game. + * + * @param name the name of the player entity to add + * @return the created player entity + */ + def addPlayer(name: String): Player = + val newPlayer = Player(name, middle) + middle.addEntity(newPlayer) + players += name -> newPlayer + newPlayer + + /** Gets the player entity with the specified name. + * + * @param name name of the player to find + * @return the player, if one with the name was found + */ + def getPlayer(name: String): Option[Player] = this.players.get(name) + + def getEntity[A >: Entity](name: String) = + this.players.getOrElse(name, this.entities.get(name)) + + /** Returns a message that is to be displayed to the player at the beginning of the game. */ + def welcomeMessage = "Generic welcome message" + + /** Plays a turn by executing the given in-game command, such as “go west”. Returns a textual + * report of what happened, or an error message if the command was unknown. In the latter + * case, no turns elapse. */ + def playTurnOfPlayer(playerName: String, command: String): Option[String] = + val action = Action(command) + val actor = this.players.get(playerName) + actor.flatMap(action.execute(_)) + +end Adventure + diff --git a/src/scalevalapokalypsi/Model/Area.scala b/src/scalevalapokalypsi/Model/Area.scala new file mode 100644 index 0000000..c891af8 --- /dev/null +++ b/src/scalevalapokalypsi/Model/Area.scala @@ -0,0 +1,117 @@ +package scalevalapokalypsi.Model + +import scala.collection.mutable.Map + +/** The class `Area` represents locations in a text adventure game world. A game world + * consists of areas. In general, an “area” can be pretty much anything: a room, a building, + * an acre of forest, or something completely different. What different areas have in + * common is that players can be located in them and that they can have exits leading to + * other, neighboring areas. An area also has a name and a description. + * @param name the name of the area + * @param description a basic description of the area (typically not including information about items) */ +class Area(val name: String, var description: String): + + private val neighbors = Map[String, Area]() + private val items: Map[String, Item] = Map() + private val entities: Map[String, Entity] = Map() + + /** Returns the area that can be reached from this area by moving in the given direction. The result + * is returned in an `Option`; `None` is returned if there is no exit in the given direction. */ + 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.values.map(_.name) + def getEntity(name: String): Option[Entity] = this.entities.get(name) + def getEntities: Iterable[Entity] = this.entities.values + + /** Tells whether this area has a neighbor in the given direction. + * + * @param direction the direction to check + * @return whether there is a neighbor in the direction + */ + def hasNeighbor(direction: String): Boolean = + this.neighbors.contains(direction) + + /** Adds an exit from this area to the given area. The neighboring area is reached by moving in + * the specified direction from this area. */ + def setNeighbor(direction: String, neighbor: Area) = + this.neighbors += direction -> neighbor + + /** Adds exits from this area to the given areas. Calling this method is equivalent to calling + * the `setNeighbor` method on each of the given direction–area pairs. + * @param exits contains pairs consisting of a direction and the neighboring area in that direction + * @see [[setNeighbor]] */ + def setNeighbors(exits: Vector[(String, Area)]) = + this.neighbors ++= exits + + /** Adds the specified item + * + * @param item the item to add + */ + def addItem(item: Item): Unit = this.items += item.name -> item + + /** Adds multiple items + * + * @param items a once iterable collection of items to add + */ + def addItems(items: IterableOnce[Item]) = + items.iterator.foreach(i => this.items += i.name -> i) + + def hasItem(itemName: String) = this.items.contains(itemName) + + + /** Removes the specified item if it exists. + * + * @param itemName the name of the item to remove + * @return an option containing the removed item + */ + def removeItem(itemName: String): Option[Item] = + this.items.remove(itemName) + + /** Adds the specified entity to the area. + * + * @param entity the entity to add. + */ + def addEntity(entity: Entity): Unit = + this.entities += entity.name.toLowerCase -> entity + + /** Removes the entity with the name `entityName`. + * + * @param entityName the name of the entity to remove + * @return an option containing the removed entity if it was in the area + */ + def removeEntity(entityName: String): Option[Entity] = + this.entities.remove(entityName.toLowerCase()) + + /** Returns a multi-line description of the area as a player sees it. This includes a basic + * description of the area as well as information about exits and items. If there are no + * items present, the return value has the form "DESCRIPTION\n\nExits available: + * DIRECTIONS SEPARATED BY SPACES". If there are one or more items present, the return + * value has the form "DESCRIPTION\nYou see here: ITEMS SEPARATED BY SPACES\n\nExits available: + * DIRECTIONS SEPARATED BY SPACES". The items and directions are listed in an arbitrary order. */ + def fullDescription: String = + val exitList = this.neighbors.keys.mkString(" ") + val itemList = this.items.keys.mkString(" ") + val entityList = this.getEntityNames.mkString(" ") + val itemDescription = + if this.items.nonEmpty then + s"\nYou see here: ${itemList}" + else "" + val entityDescription = + if this.entities.nonEmpty then + s"\nThere are entities: ${entityList}" + else "" + (this.description + + itemDescription + + entityDescription + + s"\n\nExits available: $exitList") + + + /** Returns a single-line description of the area for debugging purposes. */ + override def toString = + this.name + ": " + this.description.replaceAll("\n", " ").take(150) + +end Area + diff --git a/src/scalevalapokalypsi/Model/Entity.scala b/src/scalevalapokalypsi/Model/Entity.scala new file mode 100644 index 0000000..083c37f --- /dev/null +++ b/src/scalevalapokalypsi/Model/Entity.scala @@ -0,0 +1,103 @@ +package scalevalapokalypsi.Model + +import scala.collection.mutable.{Buffer,Map} + + + +/** A `Player` object represents a player character controlled by one real-life player + * of the program. + * + * A player object’s state is mutable: the player’s location and possessions can change, + * for instance. + * + * @param startingArea the player’s initial location */ +class Player(name: String, initialLocation: Area) extends Entity(name, initialLocation): + + private val observations: Buffer[String] = Buffer.empty + + override def observe(observation: String): Unit = + this.observations.append(observation) + + def readAndClearObservations(): Vector[String] = + val res = this.observations.toVector + observations.clear() + res + +end Player + +/** An in-game entity. + * + * @param name the name of the entity + * @param initialLocation the Area where the entity is instantiated + */ +class Entity(val name: String, initialLocation: Area): + private var currentLocation: Area = initialLocation + private var quitCommandGiven = false // one-way flag + private val inventory: Map[String, Item] = Map() + + /** Determines if the player has indicated a desire to quit the game. */ + def hasQuit = this.quitCommandGiven // TODO: This is probably unneccessary? + + /** Does nothing, except possibly in inherited classes. */ + def observe(observation: String): Unit = + println("no observation made.") + () + + /** Returns the player’s current location. */ + def location = this.currentLocation + + /** Attempts to move the player in the given direction. This is successful if there + * is an exit from the player’s current location towards the direction name. Returns + * a description of the result: "You go DIRECTION." or "You can't go DIRECTION." */ + def go(direction: String): (String, String) = + val destination = this.location.neighbor(direction) + if destination.isDefined then + val removeSuccess = this.currentLocation.removeEntity(this.name) + assert(removeSuccess.isDefined) // Production - assertions off + this.currentLocation = destination.getOrElse(this.currentLocation) + destination.foreach(_.addEntity(this)) + (s"You go $direction.", s"$name goes $direction") + else + ( + s"You can't go $direction.", + s"$name tries to go $direction and stumbles in their feet." + ) + + def pickUp(itemName: String): (String, String) = + this.currentLocation.removeItem(itemName) match + case Some(i) => + this.inventory += i.name -> i + (s"You pick up the ${i.name}", s"$name picks up the ${i.name}") + case None => (s"There is no $itemName here to pick up.", "WHAAAT THIS SHOULDN'T HAPPEN???") + + def drop(itemName: String): (String, String) = + this.inventory.remove(itemName) match + case Some(item) => + this.currentLocation.addItem(item) + (s"You drop the $itemName", s"$name drops the $itemName") + case None => ("You don't have that!", s"$name reaches their backpack to drop $itemName but miserably fails to find it there.") + + def sayTo(entity: Entity, message: String): (String, String) = + entity.observe(s"Alice: \"$message\"") + (s"You say so to ${entity.name}.", "") + + def say(message: String): (String, String) = + ("You say that aloud.", s"$name: \"$message\"") + + /** Tells whether this entity can drop the specified item + * (if an action were to specify so). + * + * @param itemName the name to check + * @return whether this entity has this item and can drop it + */ + def canDrop(itemName: String): Boolean = this.inventory.contains(itemName) + + /** Causes the player to rest for a short while (this has no substantial effect in game terms). + * Returns a description of what happened. */ + def rest(): (String, String) = + ("You rest for a while. Better get a move on, though.", "") + + /** Returns a brief description of the player’s state, for debugging purposes. */ + override def toString = "Now at: " + this.location.name + +end Entity diff --git a/src/scalevalapokalypsi/Model/Item.scala b/src/scalevalapokalypsi/Model/Item.scala new file mode 100644 index 0000000..7979480 --- /dev/null +++ b/src/scalevalapokalypsi/Model/Item.scala @@ -0,0 +1,20 @@ +package scalevalapokalypsi.Model + +import scala.annotation.targetName + +/** The class `Item` represents items in a text adventure game. Each item has a name + * and a longer description. (In later versions of the adventure game, items may + * have other features as well.) + * + * N.B. It is assumed, but not enforced by this class, that items have unique names. + * That is, no two items in a game world have the same name. + * + * @param name the item’s name + * @param description the item’s description */ +class Item(val name: String, val description: String): + + /** Returns a short textual representation of the item (its name, that is). */ + override def toString = this.name + +end Item + 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 diff --git a/src/scalevalapokalypsi/constants/constants.scala b/src/scalevalapokalypsi/constants/constants.scala new file mode 100644 index 0000000..d5abb43 --- /dev/null +++ b/src/scalevalapokalypsi/constants/constants.scala @@ -0,0 +1,19 @@ + +package scalevalapokalypsi.constants + +val MAX_MSG_SIZE = 1024 // bytes +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=";" + +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/scalevalapokalypsi/main.scala b/src/scalevalapokalypsi/main.scala new file mode 100644 index 0000000..9633f73 --- /dev/null +++ b/src/scalevalapokalypsi/main.scala @@ -0,0 +1,27 @@ +package scalevalapokalypsi + +import scalevalapokalypsi.Client.newClient +import scalevalapokalypsi.Server.Server +import java.lang.Thread +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("How do you want to play?\n1) Host and join local game\n2) Join local game\n> ") + readLine().toIntOption match + case Some(1) => + Thread(() => new Server(2267, 5, 30, true).startServer()).start() + 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/scalevalapokalypsi/utils/utils.scala b/src/scalevalapokalypsi/utils/utils.scala new file mode 100644 index 0000000..b1bac4d --- /dev/null +++ b/src/scalevalapokalypsi/utils/utils.scala @@ -0,0 +1,28 @@ +package scalevalapokalypsi.utils + +import java.io.InputStream + +/** 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 + +/** 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)) diff --git a/tyylimaare.txt b/tyylimaare.txt deleted file mode 100644 index 2de2c18..0000000 --- a/tyylimaare.txt +++ /dev/null @@ -1,157 +0,0 @@ - -Scala-tyylimääre tekstipeliin -============================= - - -Seuraa kurssin tyyliopasta ja tätä ohjetta. Noudata tätä ohjetta, jos ohjeiden -välillä on ristiriita. Kirjoita mieluummin vähemmän koodia paremmin kuin -enemmän koodia laiskasti. Näin vähennetään tulevaisuuden työtä, joka syntyy -kun koodi on lähtenyt lapasesta eikä kenelläkään ole enää mitään käsitystä -ohjelman struktuurista. Tee näin etenkin, kun kirjoittamamme koodin laatua ja -yhtenäisyyttä arvioidaan palautuksessa. - -=> https://docs.scala-lang.org/scala3/book/ca-multiversal-equality.html -Kannattaiskohan käyttää? ^^ - - -This-sanan käyttö ------------------ - -This-sanaa tulee käyttää aina, kun se on mahdollista. Se selventää sitä, kenen -muuttuja on kyseessä ja missä se on määritelty. - - -Merkkijonojen muodostaminen ---------------------------- - -Käytä s"Arvo: $a, toinen arvo: ${this.b}" jos se ei ole aivan tajuttoman -kömpelö ratkaisu tilanteeseen. Se on lähes aina parempi ilmaisu kuin merkki- -jonojen summaaminen. - - -Tyyppien kirjaaminen --------------------- - -Nimellisiin funktioihin kirjoitetaan aina paluutyyppi, vaikka se olisi Unit tai -funktio ei olisi julkinen. Paluutyypin kirjoittaminen pakottaa miettimään vielä -kerran funktion todellista tarkoitusta. Kun sen on kirjoittanut, Scalan tyyppi- -järjestelmä huomaa virheet aikaisemmin ja ne saadaan korjattua. Julkisilla -funktioilla tyyppimääre toimii hieman kuin dokumentaatio. - -Julkisille muuttujille (etenkin ohjelman laajuisille vakioille) tulee kirjata -tyypit. - - -Rivien pituudet ---------------- - -Rivin maksimipituus on 80 merkkiä. Tässä sisennykset lasketaan kahdeksaksi -merkiksi. Tämä rajoitus on ikiaikaista perua siitä, kun terminaalien standardi- -koko leveyssuunnassa oli 80 merkkiä. Tämä rajoitus on kuitenkin nykyäänkin -kätevä, koska kahta 80-merkkistä riviä on helppo pitää vierekkäin melkein näy- -töllä kuin näytöllä. Lisäksi ylipitkä rivi voi olla oire epäselkeästä koodista, -joka muutenkin kuuluisi jakaa osiin. - -Alla on esimerkkejä, miten ylipitkiä rivejä saa (ja kuuluu) jakaa. - -Esim 1 -``` -def jokuNimi(parametri1: tyyppi1, parametri2: tyyppi2, parametri3: tyyppi3): paluutyyppi = - ??? - -def jokuNimi( - parametri1: tyyppi1, - parametri2: tyyppi2, - parametri3: tyyppi3 -): paluutyyppi = - ??? -``` - -Esim 2 -``` -kokoelma.filter(_ % 2 ==0).flatten.map(_.muutaHauskallaTavalla).contains(condition) - -kokoelma - .filter(_ % 2 ==0) - .flatten - .map(_.muutaHauskallaTavalla) - .contains(condition) -``` - -Esim 3 -``` -val pitkäMuttaHyväMuuttujanNimi = PitkäOlionNimiJotaEiTahdotaMuuttaa(jokuParametri) - -val pitkäMuttaHyväMuuttujanNimi = - PitkäOlionNimiJotaEiTahdotaMuuttaa(jokuParametri) -``` - -Esim 4 -``` -val olio = MoniparametrisenOlionLuoja(parametri1, parametri2, parametri3, parametri4, parametri5) - -val olio = MoniparametrisenOlionLuoja( - parametri1, - parametri2, - parametri3, - parametri4, - parametri5 -) -``` - -Esim 5: Huomaa myös tyhjän tilan käyttö ja kommentin alku- ja loppumerkkien - paikat. Tuollainen muotoilu on nättiä ja suotavaa. -``` -/* Pitkä dokumentaatiokommentti funktiolle, jonka kuuluu olla useammalla rivillä, koska muuten koodin dokumentaatiosta ei saa selvää ja on näin ollen turhaa*/ - -/* Pitkä dokumentaatiokommentti funktiolle, jonka kuuluu olla useammalla - rivillä, koska muuten koodin dokumentaatiosta ei saa selvää ja on näin ollen - turhaa. */ -``` - - -Sisennykset ------------ - -Sisentämiseen käytetään sisennyksiä eikä välilyöntejä. Näin jokainen ohjelmoija -saa katsoa koodiansa sillä sisennyspituudella josta pitää ja säästetään muisti- -tilaa. - -IntelliJ IDEAn tapauksessa tulee siis muuttaa asetus -File - > Settings - > Editor - > Code Style - > Java - > Tabs and Indents - > Use tab character - -Kuten Linus Torvalds on sanonut, jos tiedostossa on enemmän kuin kolme -sisennettyä tasoa ([tab][tab][tab]), olet todennäköisesti eksyksissä koodissasi -ja ohjelmasi sisäinen logiikka ja rakenne kaipaa parantamista. - -Kolmen sisennyksen sääntö ei ole kiveen hakattu, koska me ei olla yhtä hyviä -ohjelmoijia kuin Torvalds. Jos kuitenkin huomaat olevasi sisennysten -viidakossa, josta ei saa selvää, niin voi olla hyvä miettiä, voisiko ohjelman -rakennetta muokata vaikkapa lisäämällä apufunktioita yms. - - -If, match, for, while jne... ----------------------------- - -Jos saatavilla on ohjelman toimintaa ja datan virtausta kuvaavia korkeamman -asteen funktioita, ja et tiedä niiden suorituskyvyn olevan heikkoa verrattnua -itse tekemääsi toteutukseen, käytä korkeamman asteen funktioita. Jos et heti -keksi sopivaa korkeamman asteen funktiota, ei se tarkoita ettei sellaista ole. -Lue läpi korkeamman asteen funktiot Scalan dokumentaatiosta, esim. - -Vector-luokan dokumentaatio: -=> https://scala-lang.org/api/3.x/scala/collection/immutable/Vector.html# - -Buffer-luokan dokumentaatio: -=> https://scala-lang.org/api/3.x/scala/collection/mutable/Buffer$.html# - -Option-luokan dokumentaatio: -=> https://scala-lang.org/api/3.x/scala/Option.html# - - -- cgit v1.2.3