From db5612ed9734d51e6fcd0d7b5a7635e49b773581 Mon Sep 17 00:00:00 2001 From: Joel Kronqvist Date: Fri, 22 Nov 2024 22:42:22 +0200 Subject: Character safety checking, supported terminals updated --- README.txt | 95 +++++++++++++-- scalevalapokalypsi.iml | 1 + src/scalevalapokalypsi/Client/Client.scala | 3 +- .../Client/ReceivedLineParser.scala | 7 +- src/scalevalapokalypsi/Client/RoomState.scala | 32 +++++ .../Client/StdinLineReader.scala | 31 ----- src/scalevalapokalypsi/Client/Turn.scala | 32 ----- src/scalevalapokalypsi/Model/Action.scala | 5 +- src/scalevalapokalypsi/Model/Area.scala | 27 ----- src/scalevalapokalypsi/Server/Client.scala | 10 +- src/scalevalapokalypsi/Server/Server.scala | 7 +- src/scalevalapokalypsi/UI/.bsp/scala.json | 45 +++++++ src/scalevalapokalypsi/UI/Printer.scala | 78 ++++++++++++ src/scalevalapokalypsi/UI/StdinLineReader.scala | 33 ++++++ src/scalevalapokalypsi/UI/main.scala | 132 +++++++++++++++++++++ src/scalevalapokalypsi/constants/constants.scala | 8 ++ src/scalevalapokalypsi/main.scala | 115 ------------------ src/scalevalapokalypsi/utils/utils.scala | 45 ++++++- 18 files changed, 477 insertions(+), 229 deletions(-) create mode 100644 src/scalevalapokalypsi/Client/RoomState.scala delete mode 100644 src/scalevalapokalypsi/Client/StdinLineReader.scala delete mode 100644 src/scalevalapokalypsi/Client/Turn.scala create mode 100644 src/scalevalapokalypsi/UI/.bsp/scala.json create mode 100644 src/scalevalapokalypsi/UI/Printer.scala create mode 100644 src/scalevalapokalypsi/UI/StdinLineReader.scala create mode 100644 src/scalevalapokalypsi/UI/main.scala delete mode 100644 src/scalevalapokalypsi/main.scala diff --git a/README.txt b/README.txt index 5f4f277..4ada829 100644 --- a/README.txt +++ b/README.txt @@ -23,18 +23,99 @@ ne olennaisesti tehdä mitä tahansa, sen sijaan että Z-kone millään lailla rajoittaisi niitä. +Järjestelmävaatimukset (TÄRKEÄÄ) +-------------------------------- + +INTELLIJ IDEA:n OLETUSTERMINAALI EI OLE TUETTU!!! Sinun on pelattava tätä +jossain tuetussa terminaalissa. Muuten tuloste tulee näyttämään oudolta +(todennäköisesti rivin alku tulostuu toistuvasti siihen, mihin yrität +kirjoittaa tai kursorisi ei pääse pois rivin alusta). Onneksi tuetut +terminaalit ovat varsin yleisiä: nopealla testillä kaikki keksityt +terminaalit olivat tuettuja IDEAa lukuun ottamatta. + +Tuettuja olivat ainakin: +* cmd.exe (dokumentaation perusteella - ei ollut saatavilla testiin) +* alacritty (toimii myös Windowsilla) +* xterm +* gnome-terminal +* konsole +* kitty +* xfce4-terminal +* jopa st (miten sekin on OOB parempi kuin IDEA?) + +Tarkemmin määriteltynä, jos uuden terminaalin lataamista ennen tahdot varmistua +kyseisen terminaalin sopivuudesta: terminaalisi on tuettava ANSI-koodeja `ESC7`, +`ESC8` ja `ESC[0E` (jossa 0 voisi muissa sovelluksissa olla jokin muukin luku). +Näiden koodien bitit ovat samassa järjestyksessä 0x001b 0x0037, 0x001b 0x0038 ja +0x001b 0x005b 0x0045, mikäli edelliset merkinnät olivat epäselvät. + + Pelin käynnistäminen -------------------- -[yksittäisen käyttäjän käynnistäminen] +Saat käynnistettyä pelin seuraavasti: +``` +1) Liity viralliselle palvelimelle (cron4.fi) +2) Käynnistä palvelin laitteellasi ja liity sille +3) Liity mielivaltaiselle palvelimelle +> 1 +Valitse itsellesi pelin sisäinen alter ego. +> [nimi] +[liityt peliin] +``` +Huomaa, että olet liittynyt julkiselle palvelimelle, jolla saattaa olla muita +pelaajia. Jos olet assari arvioimassa, kovin moni tuskin vielä tietää pelistä +taikka palvelimesta, joten pelin tila lienee jotakuinkin ennallaan. 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ä. +ystävillesi ja ala pelaamaan! Jos sinulla ei ole ystäviä, onnistuu moninpeli- +ominaisuuden testaaminen joko liittymällä useamman kerran julkiselle palveli- +melle tai pystyttämällä palvelin omalle koneelle seuraavasti `n` pelaajaa +varten. + +Prosessi 1: +``` +Miten tahdot pelata? +1) Liity viralliselle palvelimelle (cron4.fi) +2) Käynnistä palvelin laitteellasi ja liity sille +3) Liity mielivaltaiselle palvelimelle +> 2 +Valitse portti palvelimelle. (Jätä tyhjäksi oletusta 2267 varten) +> +Kuinka monta pelaajaa pelissä saa olla samanaikaisesti? +> [n] +Syötä vuorojen aikaraja yksiköttömänä sekunneissa odottelun vähentämiseksi. Syötä 0 aikarajan poistamiseksi. (Jätä tyhjäksi oletusta 30 varten) +> +Palvelin käynnistetty taustalla. +Valitse itsellesi pelin sisäinen alter ego. +> [nimi] +[liityt peliin] +``` + +n x muu prosessi: (huom. en ole prosessiteekkari) +``` +Miten tahdot pelata? +1) Liity viralliselle palvelimelle (cron4.fi) +2) Käynnistä palvelin laitteellasi ja liity sille +3) Liity mielivaltaiselle palvelimelle +> 3 +Syötä palvelimen verkkotunnus. +> 127.0.0.1 +Valitse portti palvelimelle. (Jätä tyhjäksi oletusta 2267 varten) +> +Valitse itsellesi pelin sisäinen alter ego. +> Bob +``` + +Huomaa, että pelin ajamista varten samalla koneella useampaan kertaan, on +IntelliJ IDEA:n ajokonfiguraatiota muokattava. Olettaen, että ohjelma on kerran +ajettu main-funktion play-napista, valitse play-napin vierestä valikko, jossa +lukee `main` tai `[pelin nimi]` -jotain. Sieltä valitse "Edit Configurations". +Valitse oikea kohde avautuvan ikkunan vasemmasta palkista, klikkaa kohtaa +"Modify options" ja valitse "Allow multiple instances". Sitten OK/Apply. +Jos tästä muodostuu ongelma, kannattaa ensin vähän googlata tai lukea seuraava +StackOverflow: +=> https://stackoverflow.com/questions/41226555/how-do-i-run-the-same-application-twice-in-intellij Pelin tavoite diff --git a/scalevalapokalypsi.iml b/scalevalapokalypsi.iml index a76f8e7..c38826d 100644 --- a/scalevalapokalypsi.iml +++ b/scalevalapokalypsi.iml @@ -8,5 +8,6 @@ + \ No newline at end of file diff --git a/src/scalevalapokalypsi/Client/Client.scala b/src/scalevalapokalypsi/Client/Client.scala index 0532038..e94fdb0 100644 --- a/src/scalevalapokalypsi/Client/Client.scala +++ b/src/scalevalapokalypsi/Client/Client.scala @@ -5,7 +5,7 @@ import scala.io.Source import scala.sys.process.stdout import scalevalapokalypsi.constants.* import scalevalapokalypsi.utils.{stringToByteArray,getNCharsFromSocket} -import scalevalapokalypsi.Client.{ReceivedLineParser,StdinLineReader,RoomState} +import scalevalapokalypsi.Client.{ReceivedLineParser,RoomState} import scala.concurrent.Future import scala.concurrent.ExecutionContext.Implicits.global import scala.util.{Try, Success, Failure} @@ -119,7 +119,6 @@ class Client(socket: Socket): 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() diff --git a/src/scalevalapokalypsi/Client/ReceivedLineParser.scala b/src/scalevalapokalypsi/Client/ReceivedLineParser.scala index 9337ce1..bccba59 100644 --- a/src/scalevalapokalypsi/Client/ReceivedLineParser.scala +++ b/src/scalevalapokalypsi/Client/ReceivedLineParser.scala @@ -2,6 +2,7 @@ package scalevalapokalypsi.Client import scala.collection.mutable.Buffer import scalevalapokalypsi.constants.* +import scalevalapokalypsi.utils.* /** A class for checking asynchronously for received lines */ class ReceivedLineParser: @@ -10,11 +11,11 @@ class ReceivedLineParser: private var bufferedData: Buffer[Byte] = Buffer.empty // TODO: suboptimal DS - /** Add received data */ + /** Add received data */ def in(data: Array[Byte]): Unit = this.bufferedData ++= data - /** Read a line from the received data */ + /** Read a line from the received data */ def nextLine(): Option[String] = val indexOfCRLF = this.bufferedData.indexOfSlice(CRLF) if indexOfCRLF == -1 then @@ -22,6 +23,6 @@ class ReceivedLineParser: else val splitData = this.bufferedData.splitAt(indexOfCRLF) this.bufferedData = Buffer.from(splitData(1).drop(CRLF.length)) - Some(String(splitData(0).toArray)) + byteArrayToString(splitData(0).toArray) end ReceivedLineParser diff --git a/src/scalevalapokalypsi/Client/RoomState.scala b/src/scalevalapokalypsi/Client/RoomState.scala new file mode 100644 index 0000000..02fe11b --- /dev/null +++ b/src/scalevalapokalypsi/Client/RoomState.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 RoomState: + + /** 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 RoomState diff --git a/src/scalevalapokalypsi/Client/StdinLineReader.scala b/src/scalevalapokalypsi/Client/StdinLineReader.scala deleted file mode 100644 index 6ba8761..0000000 --- a/src/scalevalapokalypsi/Client/StdinLineReader.scala +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 02fe11b..0000000 --- a/src/scalevalapokalypsi/Client/Turn.scala +++ /dev/null @@ -1,32 +0,0 @@ -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 RoomState: - - /** 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 RoomState diff --git a/src/scalevalapokalypsi/Model/Action.scala b/src/scalevalapokalypsi/Model/Action.scala index a781ee8..fdfbf75 100644 --- a/src/scalevalapokalypsi/Model/Action.scala +++ b/src/scalevalapokalypsi/Model/Action.scala @@ -29,7 +29,10 @@ class Action(input: String): val resOption: Option[(Boolean, Event)] = this.verb match case "go" => val result = actor.go(this.modifiers) - result.foreach(r => oldLocation.observeEvent(r)) + result.foreach(r => + if actor.location != oldLocation then + oldLocation.observeEvent(r) + ) result.map((true, _)) case "rest" => Some((true, actor.rest())) case "get" => Some((false, actor.pickUp(this.modifiers))) diff --git a/src/scalevalapokalypsi/Model/Area.scala b/src/scalevalapokalypsi/Model/Area.scala index 96392ba..c07f2f9 100644 --- a/src/scalevalapokalypsi/Model/Area.scala +++ b/src/scalevalapokalypsi/Model/Area.scala @@ -103,33 +103,6 @@ class Area(val name: String, var description: String): 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) diff --git a/src/scalevalapokalypsi/Server/Client.scala b/src/scalevalapokalypsi/Server/Client.scala index d689356..5727fc6 100644 --- a/src/scalevalapokalypsi/Server/Client.scala +++ b/src/scalevalapokalypsi/Server/Client.scala @@ -3,6 +3,7 @@ package scalevalapokalypsi.Server import java.net.Socket import scala.math.{min,max} import scalevalapokalypsi.constants.* +import scalevalapokalypsi.utils.* import ServerProtocolState.* import scalevalapokalypsi.Model.Action import scalevalapokalypsi.Model.Entities.Player @@ -113,8 +114,7 @@ class Client(val socket: Socket): val rest = this.incompleteMessage.drop(nextCRLF + 2) this.incompleteMessage = rest ++ Array.fill(nextCRLF + 1)(0.toByte) this.incompleteMessageIndex = 0 - // TODO: the conversion may probably be exploited to crash the server - Some(String(message)) + byteArrayToString(message) else None @@ -178,7 +178,7 @@ class Client(val socket: Socket): this.singStartTime = None - case None => + case None if isPrintable(line) => val action = Action(line) val takesATurn = this.character.exists(p => action.execute(p)) @@ -186,6 +186,10 @@ class Client(val socket: Socket): this.addDataToSend(s"$ACTION_BLOCKING_INDICATOR") this.turnUsed = true + case None => + + () // There were some illegal chars but whatever + end executeLine end Client diff --git a/src/scalevalapokalypsi/Server/Server.scala b/src/scalevalapokalypsi/Server/Server.scala index 8debdba..89db302 100644 --- a/src/scalevalapokalypsi/Server/Server.scala +++ b/src/scalevalapokalypsi/Server/Server.scala @@ -124,12 +124,7 @@ class Server( target.foreach(t => if c.player.exists(_.isSinging) && !c.clientHasSong then val verse = t.getVerseAgainst - println(s"got verse against: “$verse”") - this.writeToClient( - s"${SING_INDICATOR}$verse\r\n", - // TODO: store the verse and check how close client input is when determining sing quality - c - ) + this.writeToClient(s"${SING_INDICATOR}$verse\r\n", c) c.startSong(verse) ) ) diff --git a/src/scalevalapokalypsi/UI/.bsp/scala.json b/src/scalevalapokalypsi/UI/.bsp/scala.json new file mode 100644 index 0000000..fe879ce --- /dev/null +++ b/src/scalevalapokalypsi/UI/.bsp/scala.json @@ -0,0 +1,45 @@ +{ + "name": "scala", + "argv": [ + "/home/cron4/.cache/coursier/arc/https/github.com/scala/scala3/releases/download/3.5.0/scala3-3.5.0-x86_64-pc-linux.tar.gz/scala3-3.5.0-x86_64-pc-linux/bin/scala-cli", + "--cli-default-scala-version", + "3.5.0", + "--repository", + "file:///home/cron4/.cache/coursier/arc/https/github.com/scala/scala3/releases/download/3.5.0/scala3-3.5.0-x86_64-pc-linux.tar.gz/scala3-3.5.0-x86_64-pc-linux/maven2", + "--prog-name", + "scala", + "bsp", + "--json-options", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/UI/.scala-build/ide-options-v2.json", + "--json-launcher-options", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/UI/.scala-build/ide-launcher-options.json", + "--envs-file", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/UI/.scala-build/ide-envs.json", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/UI/main.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/UI/Printer.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Client/Client.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Client/GameEvent.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Client/ReceivedLineParser.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Client/RoomState.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Client/StdinLineReader.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Model/Action.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Model/Adventure.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Model/Area.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Model/Entities", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Model/Event.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Model/Item.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Model/SingEffects.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Server/Client.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Server/Clients.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Server/ConnectionGetter.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Server/Server.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/constants/constants.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/utils/utils.scala" + ], + "version": "1.4.0", + "bspVersion": "2.1.1", + "languages": [ + "scala", + "java" + ] +} \ No newline at end of file diff --git a/src/scalevalapokalypsi/UI/Printer.scala b/src/scalevalapokalypsi/UI/Printer.scala new file mode 100644 index 0000000..a33864f --- /dev/null +++ b/src/scalevalapokalypsi/UI/Printer.scala @@ -0,0 +1,78 @@ +package scalevalapokalypsi.UI +import scalevalapokalypsi.Client.GameEvent +import java.lang.System.currentTimeMillis +import scalevalapokalypsi.utils.isPrintable + +/** A singleton for printing. Keeps track about the "action query" at the start + * of lines and has a helper function "pryntGameEvent". + */ +object Printer: + var inputIndicatorAtStartOfLine = false + var queriedLineToSing = false + var singStartTime: Option[Long] = None + + /** Prints the given game event. + * + * @param gameEvent the event to print + */ + def printGameEvent(gameEvent: GameEvent): Unit = + + val actions = gameEvent.actions.map(_.mkString("\n")) + val roomState = gameEvent.roomState.map(_.toString) + val lineToSing = gameEvent.lineToSing + + if + inputIndicatorAtStartOfLine && + (actions.isDefined || + roomState.isDefined || + lineToSing.isDefined) + then + this.printLn("") + + actions.foreach(this.printLn(_)) + + roomState.foreach(this.printLn(_)) + + lineToSing match + case Some(l) => + if this.singStartTime.isEmpty then + this.singStartTime = Some(currentTimeMillis() / 1000) + print(s"Laula: “$l”\n ") + val timeSpent = this.singStartTime.map((t: Long) => + (currentTimeMillis / 1000 - t).toString + ).getOrElse("?") + print(this.timeIndicatorUpdater(timeSpent)) + case None => + this.singStartTime = None + + val timeLeft = s"${gameEvent.timeToNextTurn.getOrElse("∞")}" + + if + gameEvent.playerCanAct && + lineToSing.isEmpty && + !inputIndicatorAtStartOfLine + then + this.inputIndicatorAtStartOfLine = true + print(s"[$timeLeft s]> ") + + if gameEvent.playerCanAct && lineToSing.isEmpty then + print(this.timeIndicatorUpdater(timeLeft)) + + end printGameEvent + + /** Prints the given string with a trailing newline added. Should be used + * instead of ordinary println because printing outside of the Printer + * might cause weird-looking output. + * + * @param s the line to print + */ + def printLn(s: String): Unit = + if isPrintable(s) then + println(s) + else + println("Virhe: epätavallinen merkki havaittu tulosteessa.") + this.inputIndicatorAtStartOfLine = false + + private def timeIndicatorUpdater(t: String): String = + s"\u001b7\u001b[0E[$t s]> \u001b8" + diff --git a/src/scalevalapokalypsi/UI/StdinLineReader.scala b/src/scalevalapokalypsi/UI/StdinLineReader.scala new file mode 100644 index 0000000..4d0f778 --- /dev/null +++ b/src/scalevalapokalypsi/UI/StdinLineReader.scala @@ -0,0 +1,33 @@ +package scalevalapokalypsi.UI + +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)) => + if s.contains("\u0000") then + println("End of stream!") + 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/UI/main.scala b/src/scalevalapokalypsi/UI/main.scala new file mode 100644 index 0000000..44ca0e4 --- /dev/null +++ b/src/scalevalapokalypsi/UI/main.scala @@ -0,0 +1,132 @@ +package scalevalapokalypsi.UI + +import scalevalapokalypsi.Client.{newClient, Client, GameEvent} +import scalevalapokalypsi.Server.Server +import scalevalapokalypsi.constants.* +import scalevalapokalypsi.utils.* +import java.lang.Thread.sleep +import scala.util.{Try,Success,Failure} +import scala.collection.immutable.LazyList + +import java.lang.Thread +import scala.io.StdIn.readLine + + +@main def main(): Unit = + + val option = getValidInput[Int]( + "Miten tahdot pelata?\n" + + s"1) Liity viralliselle palvelimelle ($DEFAULT_SERVER)\n" + + "2) Käynnistä palvelin laitteellasi ja liity sille\n" + + "3) Liity mielivaltaiselle palvelimelle", + s => s.toIntOption match + case None => + Left("Syötä kokonaisluku.") + case Some(i) => + if i < 1 || i > 3 then + Left("Syötä kokonaisluku väliltä [1,3].") + else + Right(i) + ) + + + val serverName = + if option == 3 then + readLine("Syötä palvelimen verkkotunnus.\n> ") + else if option == 2 then + "127.0.0.1" + else + DEFAULT_SERVER + + val serverPort = + if option == 1 then + DEFAULT_PORT + else + getValidInput[Int]( + s"Valitse portti palvelimelle. (Jätä tyhjäksi oletusta $DEFAULT_PORT varten)", + s => + if s == "" then + Right(DEFAULT_PORT) + else + s.toIntOption match + case None => + Left("Syötä kokonaisluku.") + case Some(i) => + if i > 0 && i <= 65535 then + Right(i) + else + Left("Syötä portti lailliselta väliltä.") + ) + + val maxClients = if option == 2 then + getValidInput[Int]( + "Kuinka monta pelaajaa pelissä saa olla samanaikaisesti?", + s => s.toIntOption match + case None => + Left("Syötä kokonaisluku.") + case Some(i) => + if i > 1000 then + println( + "Aika ison määrän valitsit, mutta olkoon menneeksi." + ) + if i > 0 then + Right(i) + else + Left("Syötä positiivinen kokonaisluku.") + ) + else + 0 + + val timeLimit = if option == 2 then + getValidInput[Int]( + s"Syötä vuorojen aikaraja yksiköttömänä sekunneissa odottelun vähentämiseksi. Syötä 0 aikarajan poistamiseksi. (Jätä tyhjäksi oletusta $DEFAULT_TURN_TIME_LIMIT varten)", + s => if s == "" then + Right(DEFAULT_TURN_TIME_LIMIT) + else + s.toIntOption + .toRight("Syötä kokonaisluku") + .filterOrElse( + _ >= 0, + "Syötä epänegatiivinen kokonaisluku." + ) + ) + else + 0 + + if option == 2 then + Thread(() => new Server(serverPort, maxClients, timeLimit, true).startServer()).start() + println("Palvelin käynnistetty taustalla.") + + val name = readLine("Valitse itsellesi pelin sisäinen alter ego.\n> ") + + Try(newClient(name, serverName, serverPort)) + .toOption + .flatten match + case Some(client) => + startClient(client) + case None => + println( + "Serverille liittyminen epäonnistui. Tarkista internet-yhteytesi. Jos yhteytesi on kunnossa ja liittyminen ei pian onnistu, ota yhteyttä Joel Kronqvistiin olettaen, ettei vuodesta 2024 ole kulunut kohtuuttomasti aikaa." + ) + + + +/** Client game loop. Handles output to and from the client in the eyes of the + * terminal. + */ +def startClient(client: Client): Unit = + var hasQuit = false + val stdinReader = StdinLineReader() + stdinReader.startReading() + while !hasQuit do + sleep(POLL_INTERVAL) + val line = stdinReader.newLine() + if line.map(_.length).getOrElse(0) > 1024 then + Printer.printLn("Virhe: Syötteesi oli liian pitkä.") + else if line == Some("quit") then + hasQuit = true + else + val gameEvent = client.clientStep(line) + Printer.printGameEvent(gameEvent) + + diff --git a/src/scalevalapokalypsi/constants/constants.scala b/src/scalevalapokalypsi/constants/constants.scala index 7d4e1a6..970d6f7 100644 --- a/src/scalevalapokalypsi/constants/constants.scala +++ b/src/scalevalapokalypsi/constants/constants.scala @@ -10,9 +10,17 @@ val SING_INDICATOR = "~" val ACTION_BLOCKING_INDICATOR='.' val ACTION_NONBLOCKING_INDICATOR='+' val INITIAL_CONN_TIMEOUT = 5000 // millisec. +val DEFAULT_PORT: Int = 2267 +val DEFAULT_TURN_TIME_LIMIT = 30 +val DEFAULT_SERVER = "cron4.fi" val LIST_SEPARATOR=";" +val BYTE_ALLOWED = + (n: Byte) => !(n <= 31 || (n >= 127 && n <= 159)) + +val FORBIDDEN_CHARACTERS = "\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007\u0008\u0009\u000B\u000C\u000D\u000E\u000F\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u001A\u001B\u001C\u001D\u001E\u001F\u007F\u0080\u0081\u0082\u0083\u0084\u0085\u0086\u0087\u0088\u0089\u008A\u008B\u008C\u008D\u008E\u008F\u0090\u0091\u0092\u0093\u0094\u0095\u0096\u0097\u0098\u0099\u009A\u009B\u009C\u009D\u009E\u009F" + val PROTOCOL_VERSION_GOOD = "1" val PROTOCOL_VERSION_BAD = "0" // assert(PROTOCOL_VERSION_BAD.length <= PROTOCOL_VERSION_GOOD.length) diff --git a/src/scalevalapokalypsi/main.scala b/src/scalevalapokalypsi/main.scala deleted file mode 100644 index e357845..0000000 --- a/src/scalevalapokalypsi/main.scala +++ /dev/null @@ -1,115 +0,0 @@ -package scalevalapokalypsi - -import scalevalapokalypsi.Client.{newClient, Client, StdinLineReader, GameEvent} -import scalevalapokalypsi.Server.Server -import scalevalapokalypsi.constants.* -import java.lang.Thread.sleep -import java.lang.System.currentTimeMillis -import scala.util.Try - -import java.lang.Thread -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() - Try(newClient(name, "127.0.0.1", 2267)) - .toOption - .flatten match - case Some(client) => - startClient(client) - case None => - println("Starting the client failed.") - - case Some(2) => - print("Choose a name:\n> ") - val name = readLine() - Try(newClient(name, "127.0.0.1", 2267)) - .toOption - .flatten match - case Some(client) => - startClient(client) - case None => - println("Starting the client failed.") - case _ => println("Invalid input") - -def startClient(client: Client): Unit = - var hasQuit = false - val stdinReader = StdinLineReader() - stdinReader.startReading() - val printer = Printer() - while !hasQuit do - sleep(POLL_INTERVAL) - val line = stdinReader.newLine() - if line.map(_.length).getOrElse(0) > 1024 then - printer.printLn("Virhe: Syötteesi oli liian pitkä.") - else if line == Some("quit") then - hasQuit = true - else - val gameEvent = client.clientStep(line) - printer.printGameEvent(gameEvent) - - -class Printer: - var inputIndicatorAtStartOfLine = false - var queriedLineToSing = false - var singStartTime: Option[Long] = None - - def printGameEvent(gameEvent: GameEvent): Unit = - - val actions = gameEvent.actions.map(_.mkString("\n")) - val roomState = gameEvent.roomState.map(_.toString) - val lineToSing = gameEvent.lineToSing - - if - inputIndicatorAtStartOfLine && - (actions.isDefined || - roomState.isDefined || - lineToSing.isDefined) - then - this.printLn("") - - actions.foreach(this.printLn(_)) - - roomState.foreach(this.printLn(_)) - - lineToSing match - case Some(l) => - if this.singStartTime.isEmpty then - this.singStartTime = Some(currentTimeMillis() / 1000) - print(s"Laula: “$l”\n ") - val timeSpent = this.singStartTime.map((t: Long) => - (currentTimeMillis / 1000 - t).toString - ).getOrElse("?") - print(this.timeIndicatorUpdater(timeSpent)) - case None => - this.singStartTime = None - - val timeLeft = s"${gameEvent.timeToNextTurn.getOrElse("∞")}" - - if - gameEvent.playerCanAct && - lineToSing.isEmpty && - !inputIndicatorAtStartOfLine - then - this.inputIndicatorAtStartOfLine = true - print(s"[$timeLeft s]> ") - - if gameEvent.playerCanAct && lineToSing.isEmpty then - print(this.timeIndicatorUpdater(timeLeft)) - - end printGameEvent - - def printLn(s: String): Unit = - println(s) - this.inputIndicatorAtStartOfLine = false - - private def timeIndicatorUpdater(t: String): String = - s"\u001b[s\u001b[0E[$t s]> \u001b[u" - diff --git a/src/scalevalapokalypsi/utils/utils.scala b/src/scalevalapokalypsi/utils/utils.scala index ab262ad..54d407b 100644 --- a/src/scalevalapokalypsi/utils/utils.scala +++ b/src/scalevalapokalypsi/utils/utils.scala @@ -2,6 +2,9 @@ package scalevalapokalypsi.utils import java.io.InputStream import java.nio.charset.StandardCharsets +import scala.util.Try +import scalevalapokalypsi.constants.* +import scala.io.StdIn.readLine /** Converts this string to an array of bytes (probably for transmission). * @@ -11,6 +14,16 @@ import java.nio.charset.StandardCharsets def stringToByteArray(str: String): Array[Byte] = str.getBytes(StandardCharsets.UTF_8) +/** Converts the given byte array to a string if possible*. + * (* Doesn't convert strings with control sequences in them) + * + * @param bytes the byte array to convert + * @return the matching string, if possible*. + */ +def byteArrayToString(bytes: Array[Byte]): Option[String] = + Try(String(bytes, StandardCharsets.UTF_8)) + .toOption + /** Reads n characters from the given InputStream blockingly. * * @param input the InputStream to read from @@ -25,5 +38,33 @@ def getNCharsFromSocket(input: InputStream, n: Int): Option[String] = 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, StandardCharsets.UTF_8)) + if failed then None else byteArrayToString(buffer) + +def isPrintable(s: String): Boolean = + FORBIDDEN_CHARACTERS.forall((c: Char) => !(s contains c)) + + +/** Gets input from STDIN until the line entered is appoved + * by a validator function. + * + * @param message A query to represent the user with when requesting input + * @param validator A validator function. Should return Right[A] when the + * input is valid and Left[String] when the input is invalid, + * where the string will be printed to the user to notify + * their input was invalid. + * @return The first encountered valid string, see `validator`. + */ +def getValidInput[A]( + message: String, + validator: String => Either[String, A] +): A = + LazyList.continually(readLine(s"$message\n> ")) + .flatMap(input => + validator(input) match + case Left(s) => + println(s) + None + case Right(a) => + Some(a) + ).head + -- cgit v1.2.3